Compare commits

...
26 Commits
Author SHA1 Message Date
Linus Rath 61690603a2 chore: update version number to 0.1.73 2026-10-10 23:54:40 +02:00
Linus Rath 765ff26aa5 Merge remote-tracking branch 'origin/main' 2026-10-10 23:20:25 +02:00
Linus Rath b832ac0c49 feat(calendar): tap an empty time slot to start a new event
In the day, 3 days and week grids an event could only be started with a
long press, which few people find. Tapping an empty slot now marks it
with a "New event" block, and tapping that block opens the editor at
that hour; a second tap elsewhere moves the block. Long press still
opens the editor straight away. Tapping a day's header in the week or
3 days grid opens that day.

The editor also started events from a slot half an hour late, because
it rounded a time already on the hour up to the next half hour.
2026-10-10 22:59:35 +02:00
Linus Rath f53cee7ec9 feat(calendar): lay out the agenda as a schedule with dates on the left
The agenda showed each day as a grey header bar above its events. It now
reads like a schedule: the weekday and date sit in a column on the left
of the day's first event (today in a filled circle), a heading marks
where each month starts and a quiet line names each week's range. An
empty today says "Nothing planned. Tap to create." and tapping it opens
a new event. In the drawer the view is now called Schedule.

Events that fill a whole day are listed as all day instead of
"00:00 – 00:00".
2026-10-10 22:53:40 +02:00
Linus Rath 21d469f7e8 feat(calendar): show the month as a full-screen grid of events
The month view used to be a small grid of dots with the selected day's
events listed underneath, so you only ever saw one day's events at a
time. It now fills the screen: each day is a cell with its events as
solid bars in the calendar colour and "+N" for the ones that don't fit.
Tapping a day opens it in the day view, and long-pressing still creates
an event there. Events that fill the whole day no longer show 00:00.

The dotted month stays for the month that drops down under the title.
2026-10-10 22:47:13 +02:00
Linus Rath c1e0cb36c9 feat(calendar): move views into the drawer and add a create button
The calendar header was crowded: four icon toggles, a separate row of
arrows and a Today button, and a small create button squeezed in next
to them. Navigation now follows Google Calendar's layout while keeping
the app's own look:

- the header shows the month; tapping it drops down a month you can
  swipe through and pick a day from
- a calendar icon with today's date jumps to today
- Schedule, Day, 3 days, Week and Month are listed at the top of the
  drawer, styled like the mail drawer's folders, with Refresh below
- a round create button sits in the corner like the mail compose
  button; with tasks on it offers Event or Task
- a new 3 days view shows three columns of the time grid

The arrows stay in the header when free scrolling is off, since the
views can't be swiped then.
2026-10-10 22:41:46 +02:00
Linus Rath d8879617ed fix: never destroy mail from the notification Delete action #68
With no Trash mailbox, or when Mailbox/get came back as an error, the
Delete action fell through to destroying the email. A shared account
without its own Trash lost mail permanently on one tap.

- only move to Trash; do nothing when there is no Trash or the lookup
  failed
- check that Email/set actually updated the email
- show "(No Subject)" in the collapsed text instead of a blank line
- require unlock for Delete (Android 12+)
2026-10-10 19:20:53 +02:00
Linus Rath 7b62755aee Merge pull request #68 from arnauda-gh/feat/notification-preview-and-actions
feat(notifications): add rich preview snippet and quick actions (mark read, delete, reply)
2026-10-10 19:20:46 +02:00
Linus Rath 96e1cc1c3f fix: open a list mail without mounting every pane before it #67
The tap fix makes a notification open a single page, so the viewer no
longer needs to render up to the opened index. That setting mounted
about N mail panes on every list open, on iOS too. Also drop the
onScrollToIndexFailed handler, which FlatList never calls when
getItemLayout is set.
2026-10-10 19:20:46 +02:00
Linus Rath ec137e931b Merge pull request #67 from arnauda-gh/fix/notification-open-target-email
fix(notifications): ensure notification tap always opens target email
2026-10-10 19:20:40 +02:00
Linus Rath cf78b07939 Merge pull request #69 from arnauda-gh/fix/quick-reply-keyboard-avoidance
fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears
2026-10-10 19:20:39 +02:00
arnauda-gh 5237d9004d fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears 2026-10-09 15:00:51 +02:00
arnauda-gh be15af5d4c fix(notifications): execute background actions directly via HeadlessJs without service 2026-10-09 12:41:53 +02:00
arnauda-gh 90fe43882a feat(notifications): add email preview snippet and quick actions (mark read, delete, reply) 2026-10-09 09:16:53 +02:00
arnauda-gh 465eeffd1e fix(viewer): use info.index * windowWidth for onScrollToIndexFailed offset 2026-10-09 09:05:27 +02:00
arnauda-gh 200404483c fix(notifications): ensure notification tap always opens target email 2026-10-09 09:01:28 +02:00
Linus Rath 28f6787279 fix: confirm a copied event location on iOS too #59 2026-10-06 23:32:04 +02:00
Linus Rath 89f0524ad4 fix: only offer meeting links the app can open #59 2026-10-06 23:30:57 +02:00
Linus Rath 172110f7b0 Merge pull request #59 from lucletoffe/fix/calendar-location-links
fix(calendar): join the meeting and open the location from the event sheet
2026-10-06 23:30:07 +02:00
Linus Rath aa612e6f0e Merge pull request #55 from michaelbrusegard/fix/external-idp-handoff
fix(auth): verify external identity providers during handoff
2026-10-06 23:29:29 +02:00
Linus Rath 76180b332d ci: put every iOS release on the public TestFlight link
Uploads only reached internal testers. The public link's "external" group
still served 0.1.66, because nothing added later builds to it or
submitted them for Beta App Review.

A new `public-testflight` job runs after the upload on a Linux runner.
It waits for App Store Connect to process the build, adds it to the
external group, sets "What to Test" to the GitHub release and submits
it for review (`scripts/testflight-public.mjs`, safe to re-run). The
README links the TestFlight beta and the latest APK.
2026-10-02 16:33:07 +02:00
Linus Rath f5c491f6d7 chore: update version number to 0.1.72 2026-10-02 15:02:12 +02:00
Linus Rath 23af324d79 fix: verify app updates without a second slow progress bar
Since releases ship an `.apk.sha256` file, every in-app update checks
the downloaded APK against it. The check read the 40 MB file in base64
chunks and hashed it in JS, which took longer than the download, and it
reset the progress bar to 0 under the "Downloading" label, so the update
looked like it downloaded twice, the second time slowly.

The file is now read and hashed natively through expo-crypto (the JS
hasher remains as a fallback), and the bar stays full under a
"Verifying…" label while the check runs.
2026-10-02 15:02:10 +02:00
lucletoffeandClaude Opus 5.5 bd1b2f3fd3 fix(calendar): keep app-scheme meeting links and match service labels exactly
A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last
resort instead of hiding the meeting row; script schemes never are. A
location only joins the meeting when the whole label is a service name, so
"Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:59:58 +02:00
lucletoffeandClaude Opus 5.5 7c4b584d9c fix(calendar): join the meeting and open the location from the event sheet
Invitations - Teams above all - leave virtualLocations empty: LOCATION reads
"Microsoft Teams Meeting" and the join URL sits in the description between
help and dial-in links, so the sheet showed no meeting button. Find the
join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi,
Whereby, GoTo) in the location or description, unwrapping Outlook Safe
Links, and offer it as the meeting link. Nothing is written to the event.

The location was plain text. Tapping it now opens it: a URL opens as a
link, a location that only names the service joins the meeting, anything
else is searched in the maps app (geo: intent on Android, Google Maps
universal link elsewhere). Long-press copies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:47:15 +02:00
Michael Brusegard e35cd82f5f fix(auth): verify external identity providers during handoff
Allow webmail-configured OAuth providers on separate hosts without trusting callback-supplied endpoints or broadly allowing sibling domains. Bind the client, JMAP server, issuer and token endpoint to HTTPS metadata from the selected webmail for both handoff and pairing.
2026-09-18 04:01:58 -07:00
67 changed files with 2778 additions and 476 deletions
+35
View File
@@ -25,6 +25,9 @@ jobs:
timeout-minutes: 90
permissions:
contents: write
outputs:
version: ${{ steps.export.outputs.version }}
uploaded: ${{ steps.upload.outputs.done }}
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
@@ -254,6 +257,7 @@ jobs:
if-no-files-found: error
- name: Upload to TestFlight
id: upload
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
@@ -271,4 +275,35 @@ jobs:
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "done=true" >> "$GITHUB_OUTPUT"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
# Puts the upload on the public TestFlight link (README): waits for App Store
# Connect to process it, adds it to the external group and submits it for
# beta review. On Linux, because most of this job is waiting.
public-testflight:
needs: build
if: needs.build.outputs.uploaded == 'true'
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install the App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Add the build to the public group and submit it for beta review
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
+2
View File
@@ -119,9 +119,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
emailIds: [payload.emailId],
subject: payload.subject,
// A group mailbox's message lives under another JMAP account (#839).
jmapAccountId: notificationTapJmapAccountId(payload),
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
});
}
+6 -1
View File
@@ -14,6 +14,11 @@
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## Try it
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
@@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
## Run locally
+1 -1
View File
@@ -1 +1 @@
0.1.71
0.1.73
+1
View File
@@ -76,6 +76,7 @@
<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"/>
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
@@ -4,6 +4,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
?: accountId ?: "Bulwark Mail"
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
thread(name = "bulwark-notification") {
val largeIcon = iconUrl?.let { fetchBitmap(it) }
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
notificationId, title, body, preview, largeIcon, bgColorHex,
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
groupTitle, markReadLabel, deleteLabel, replyLabel,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
notificationId: String,
title: String,
body: String,
preview: String?,
largeIcon: Bitmap,
colorHex: String,
emailId: String?,
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
accountId: String?,
jmapAccountId: String?,
groupKey: String?,
groupTitle: String?,
markReadLabel: String,
deleteLabel: String,
replyLabel: String,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-notification://$notificationId")
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val bigText = buildString {
if (!subject.isNullOrBlank()) append(subject)
if (!preview.isNullOrBlank()) {
if (isNotEmpty()) append("\n")
append(preview)
}
}
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(largeIcon)
.setContentTitle(title)
// body is the subject with the "(No subject)" fallback; an empty
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(if (bigText.isNotBlank()) bigText else body)
.setBigContentTitle(title)
.setSummaryText(groupTitle)
)
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
if (emailId != null && accountId != null) {
// Action 1: Mark as read
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
action = ACTION_MARK_READ
data = Uri.parse("bulwark-action-read://$notificationId")
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
putExtra(EXTRA_ACTION, "markRead")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val markReadPending = PendingIntent.getBroadcast(
ctx,
(notificationId + ":read").hashCode(),
markReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
)
// Action 2: Delete
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
action = ACTION_DELETE
data = Uri.parse("bulwark-action-delete://$notificationId")
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
putExtra(EXTRA_ACTION, "delete")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val deletePending = PendingIntent.getBroadcast(
ctx,
(notificationId + ":delete").hashCode(),
deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
// Android 12+: unlock first, so a locked phone can't delete mail.
.setAuthenticationRequired(true)
.build()
)
// Action 3: Reply
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-reply://$notificationId")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
putExtra(EXTRA_ACTION, "reply")
}
val replyPending = PendingIntent.getActivity(
ctx,
(notificationId + ":reply").hashCode(),
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
)
}
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
it.groupKey?.endsWith(groupKey) == true &&
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
}
val count = maxOf(children.size, 1)
if (children.size < 2) {
manager.cancel(groupKey, groupKey.hashCode())
return
}
val count = children.size
val inbox = NotificationCompat.InboxStyle()
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
val extras = sbn.notification.extras
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-group-summary://$groupKey")
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
@Volatile private var currentInstance: BulwarkFcmModule? = null
const val EXTRA_ACTION = "bulwark.notification.action"
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
fun emit(eventName: String, params: WritableMap?) {
val module = currentInstance ?: return
val ctx = module.reactApplicationContext
@@ -0,0 +1,124 @@
package com.anonymous.bulwarkmobile
import android.app.Application
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
// Dismiss the notification immediately from the tray
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationId != null) {
manager.cancel(notificationId, notificationId.hashCode())
}
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
return
}
// On Android 8+, a broadcast receiver in background cannot start a Service
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
val pendingResult = goAsync()
val app = context.applicationContext as Application
val mainHandler = Handler(Looper.getMainLooper())
// Safety timeout to ensure pendingResult.finish() is always called
val timeoutRunnable = Runnable {
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
try {
pendingResult.finish()
} catch (_: Exception) {}
}
mainHandler.postDelayed(timeoutRunnable, 20_000L)
try {
HeadlessJs.withReadyReactContext(app) { reactContext ->
if (!reactContext.hasActiveReactInstance()) {
Log.w(TAG, "React context does not have active instance")
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
return@withReadyReactContext
}
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
val taskBundle = Bundle().apply {
putString("action", action)
putString("emailId", emailId)
putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
// Keep raw extras as well
putString(BulwarkFcmModule.EXTRA_ACTION, action)
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val config = HeadlessJsTaskConfig(
"BulwarkNotificationAction",
Arguments.fromBundle(taskBundle),
15_000L,
true // allowExecutionInForeground
)
val listener = object : HeadlessJsTaskEventListener {
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
override fun onHeadlessJsTaskFinish(taskId: Int) {
Log.i(TAG, "Notification action task $taskId finished for action $action")
tasks.removeTaskEventListener(this)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
}
tasks.addTaskEventListener(listener)
HeadlessJs.startTask(
tasks,
config,
onStarted = { taskId ->
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
},
onRefused = { e ->
Log.w(TAG, "Notification action task refused: ${e.message}")
tasks.removeTaskEventListener(listener)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
}
companion object {
private const val TAG = "BulwarkNotifAction"
}
}
@@ -43,7 +43,8 @@ object NotificationTapStore {
// The JMAP account a group/shared mailbox's message lives in - a JMAP
// id like the email id. Optional: older notifications don't carry it.
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
@@ -51,6 +52,7 @@ object NotificationTapStore {
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
extras.remove(EXTRA_ACTION)
return payload
}
@@ -60,6 +62,7 @@ object NotificationTapStore {
val subject: String?,
val accountId: String?,
val jmapAccountId: String?,
val action: String? = null,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
@@ -67,6 +70,7 @@ object NotificationTapStore {
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
if (action != null) putString("action", action)
}
}
@@ -75,4 +79,5 @@ object NotificationTapStore {
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
const val EXTRA_ACTION = "bulwark.notification.action"
}
+14
View File
@@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it
immediately. External testers (up to 10,000) need a Beta App Review, which takes
about a day the first time and is usually waved through afterwards.
The workflow's `public-testflight` job does the external part on its own. It
waits for processing, adds the build to the `external` group (the one behind
the public link in the README), sets "What to Test" to the GitHub release and
submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by
hand for a build, for example after a failed run:
```bash
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number
```
If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool
reports it as "Cannot determine the Apple ID from Bundle ID"), the Account
Holder has to accept an updated agreement under Business in App Store Connect.
## What does not work on iOS yet
- **Push notifications.** The push path is a hand-written Android module talking
+6
View File
@@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
});
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
});
// Device sync (Android, #34): the contacts and calendar sync adapters run the
// sync engine through this task, headless or next to the UI. The engine is
// required on first use so an app start does not load it.
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "رابط",
"export_ics_short": ".ics",
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
"tap_to_create": "اضغط + لإنشاء حدث"
},
"fab": {
"event": "حدث",
"task": "مهمة"
},
"form": {
"description_placeholder": "ملاحظات، جدول أعمال…",
"location_placeholder": "المكان أو العنوان",
@@ -127,6 +132,10 @@
"not_found": "لم تعد هذه المهمة متاحة.",
"save_error": "فشل حفظ المهمة",
"update_error": "فشل تحديث المهمة"
},
"views": {
"schedule": "الجدول",
"three_days": "3 أيام"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
"severity_deprecated": "إلزامي",
"severity_security": "أمني",
"title": "تحديثات التطبيق"
"title": "تحديثات التطبيق",
"verifying": "جارٍ التحقق…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Enllaç",
"export_ics_short": ".ics",
"nothing_planned": "Res planificat. Toca per crear.",
"tap_to_create": "Toqueu + per crear-ne un"
},
"fab": {
"event": "Esdeveniment",
"task": "Tasca"
},
"form": {
"description_placeholder": "Notes, ordre del dia…",
"location_placeholder": "Lloc o adreça",
@@ -127,6 +132,10 @@
"not_found": "Aquesta tasca ja no està disponible.",
"save_error": "No s'ha pogut desar la tasca",
"update_error": "No s'ha pogut actualitzar la tasca"
},
"views": {
"schedule": "Agenda",
"three_days": "3 dies"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
"severity_deprecated": "Obligatòria",
"severity_security": "Seguretat",
"title": "Actualitzacions de l'aplicació"
"title": "Actualitzacions de l'aplicació",
"verifying": "S'està verificant…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Odkaz",
"export_ics_short": ".ics",
"nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.",
"tap_to_create": "Událost vytvoříte klepnutím na +"
},
"fab": {
"event": "Událost",
"task": "Úkol"
},
"form": {
"description_placeholder": "Poznámky, program…",
"location_placeholder": "Místo nebo adresa",
@@ -127,6 +132,10 @@
"not_found": "Tento úkol už není k dispozici.",
"save_error": "Úkol se nepodařilo uložit",
"update_error": "Úkol se nepodařilo aktualizovat"
},
"views": {
"schedule": "Program",
"three_days": "3 dny"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečení",
"title": "Aktualizace aplikace"
"title": "Aktualizace aplikace",
"verifying": "Ověřování…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Intet planlagt. Tryk for at oprette.",
"tap_to_create": "Tryk på + for at oprette en"
},
"fab": {
"event": "Begivenhed",
"task": "Opgave"
},
"form": {
"description_placeholder": "Noter, dagsorden…",
"location_placeholder": "Sted eller adresse",
@@ -127,6 +132,10 @@
"not_found": "Denne opgave er ikke længere tilgængelig.",
"save_error": "Kunne ikke gemme opgaven",
"update_error": "Kunne ikke opdatere opgaven"
},
"views": {
"schedule": "Tidsplan",
"three_days": "3 dage"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
"severity_deprecated": "Påkrævet",
"severity_security": "Sikkerhed",
"title": "Appopdateringer"
"title": "Appopdateringer",
"verifying": "Verificerer…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nichts geplant. Zum Erstellen tippen.",
"tap_to_create": "Tippen Sie auf +, um einen zu erstellen"
},
"fab": {
"event": "Termin",
"task": "Aufgabe"
},
"form": {
"description_placeholder": "Notizen, Tagesordnung…",
"location_placeholder": "Ort oder Adresse",
@@ -127,6 +132,10 @@
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
"save_error": "Aufgabe konnte nicht gespeichert werden",
"update_error": "Aufgabe konnte nicht aktualisiert werden"
},
"views": {
"schedule": "Terminübersicht",
"three_days": "3 Tage"
}
},
"common": {
@@ -1168,7 +1177,8 @@
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
"severity_deprecated": "Erforderlich",
"severity_security": "Sicherheit",
"title": "App-Updates"
"title": "App-Updates",
"verifying": "Wird überprüft…"
},
"widgets": {
"attachments": {
+14 -1
View File
@@ -13,6 +13,9 @@
"indigo": "Indigo",
"teal": "Teal"
},
"detail": {
"location_hint": "Opens the location; long-press to copy it"
},
"drawer": {
"my_calendars": "My calendars",
"no_calendars": "No calendars yet.",
@@ -25,8 +28,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nothing planned. Tap to create.",
"tap_to_create": "Tap + to create one"
},
"fab": {
"event": "Event",
"task": "Task"
},
"form": {
"description_placeholder": "Notes, agenda…",
"location_placeholder": "Place or address",
@@ -127,6 +135,10 @@
"not_found": "This task is no longer available.",
"save_error": "Failed to save task",
"update_error": "Failed to update task"
},
"views": {
"schedule": "Schedule",
"three_days": "3 days"
}
},
"common": {
@@ -1184,7 +1196,8 @@
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
"severity_deprecated": "Required",
"severity_security": "Security",
"title": "App updates"
"title": "App updates",
"verifying": "Verifying…"
},
"widgets": {
"attachments": {
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Enlace",
"export_ics_short": ".ics",
"nothing_planned": "Nada planificado. Toca para crear.",
"tap_to_create": "Toca + para crear uno"
},
"fab": {
"event": "Evento",
"task": "Tarea"
},
"form": {
"description_placeholder": "Notas, orden del día…",
"location_placeholder": "Lugar o dirección",
@@ -127,6 +132,10 @@
"not_found": "Esta tarea ya no está disponible.",
"save_error": "No se pudo guardar la tarea",
"update_error": "No se pudo actualizar la tarea"
},
"views": {
"schedule": "Agenda",
"three_days": "3 días"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
"severity_deprecated": "Obligatoria",
"severity_security": "Seguridad",
"title": "Actualizaciones de la app"
"title": "Actualizaciones de la app",
"verifying": "Verificando…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "لینک",
"export_ics_short": ".ics",
"nothing_planned": "برنامه‌ای نیست. برای ایجاد ضربه بزنید.",
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
},
"fab": {
"event": "رویداد",
"task": "کار"
},
"form": {
"description_placeholder": "یادداشت‌ها، دستور جلسه…",
"location_placeholder": "مکان یا نشانی",
@@ -127,6 +132,10 @@
"not_found": "این وظیفه دیگر در دسترس نیست.",
"save_error": "ذخیره وظیفه ناموفق بود",
"update_error": "به‌روزرسانی وظیفه ناموفق بود"
},
"views": {
"schedule": "برنامه",
"three_days": "۳ روز"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
"severity_deprecated": "الزامی",
"severity_security": "امنیتی",
"title": "به‌روزرسانی‌های برنامه"
"title": "به‌روزرسانی‌های برنامه",
"verifying": "در حال بررسی…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Lien",
"export_ics_short": ".ics",
"nothing_planned": "Rien de prévu. Touchez pour créer.",
"tap_to_create": "Appuyez sur + pour en créer un"
},
"fab": {
"event": "Événement",
"task": "Tâche"
},
"form": {
"description_placeholder": "Notes, ordre du jour…",
"location_placeholder": "Lieu ou adresse",
@@ -127,6 +132,10 @@
"not_found": "Cette tâche n'est plus disponible.",
"save_error": "Échec de l'enregistrement de la tâche",
"update_error": "Échec de la mise à jour de la tâche"
},
"views": {
"schedule": "Planning",
"three_days": "3 jours"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
"severity_deprecated": "Requise",
"severity_security": "Sécurité",
"title": "Mises à jour de l'application"
"title": "Mises à jour de l'application",
"verifying": "Vérification…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "קישור",
"export_ics_short": ".ics",
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
"tap_to_create": "הקש על + כדי ליצור"
},
"fab": {
"event": "אירוע",
"task": "משימה"
},
"form": {
"description_placeholder": "הערות, סדר יום…",
"location_placeholder": "מקום או כתובת",
@@ -127,6 +132,10 @@
"not_found": "המשימה הזו אינה זמינה עוד.",
"save_error": "שמירת המשימה נכשלה",
"update_error": "עדכון המשימה נכשל"
},
"views": {
"schedule": "לוח זמנים",
"three_days": "3 ימים"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
"severity_deprecated": "חובה",
"severity_security": "אבטחה",
"title": "עדכוני אפליקציה"
"title": "עדכוני אפליקציה",
"verifying": "מאמת…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Hivatkozás",
"export_ics_short": ".ics",
"nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.",
"tap_to_create": "Esemény létrehozásához koppints a + gombra"
},
"fab": {
"event": "Esemény",
"task": "Feladat"
},
"form": {
"description_placeholder": "Jegyzetek, napirend…",
"location_placeholder": "Hely vagy cím",
@@ -127,6 +132,10 @@
"not_found": "Ez a feladat már nem érhető el.",
"save_error": "Nem sikerült menteni a feladatot",
"update_error": "Nem sikerült frissíteni a feladatot"
},
"views": {
"schedule": "Ütemezés",
"three_days": "3 nap"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
"severity_deprecated": "Kötelező",
"severity_security": "Biztonsági",
"title": "Alkalmazásfrissítések"
"title": "Alkalmazásfrissítések",
"verifying": "Ellenőrzés…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Niente in programma. Tocca per creare.",
"tap_to_create": "Tocca + per crearne uno"
},
"fab": {
"event": "Evento",
"task": "Attività"
},
"form": {
"description_placeholder": "Note, ordine del giorno…",
"location_placeholder": "Luogo o indirizzo",
@@ -127,6 +132,10 @@
"not_found": "Questa attività non è più disponibile.",
"save_error": "Impossibile salvare l'attività",
"update_error": "Impossibile aggiornare l'attività"
},
"views": {
"schedule": "Programma",
"three_days": "3 giorni"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
"severity_deprecated": "Obbligatorio",
"severity_security": "Sicurezza",
"title": "Aggiornamenti dell'app"
"title": "Aggiornamenti dell'app",
"verifying": "Verifica in corso…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "リンク",
"export_ics_short": ".ics",
"nothing_planned": "予定はありません。タップして作成",
"tap_to_create": "+ をタップして作成"
},
"fab": {
"event": "予定",
"task": "タスク"
},
"form": {
"description_placeholder": "メモ、議題…",
"location_placeholder": "場所または住所",
@@ -127,6 +132,10 @@
"not_found": "このタスクは利用できなくなりました。",
"save_error": "タスクの保存に失敗しました",
"update_error": "タスクの更新に失敗しました"
},
"views": {
"schedule": "スケジュール",
"three_days": "3 日"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
"severity_deprecated": "必須",
"severity_security": "セキュリティ",
"title": "アプリのアップデート"
"title": "アプリのアップデート",
"verifying": "検証中…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "링크",
"export_ics_short": ".ics",
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
"tap_to_create": "+를 눌러 만들어 보세요"
},
"fab": {
"event": "일정",
"task": "할 일"
},
"form": {
"description_placeholder": "메모, 안건…",
"location_placeholder": "장소 또는 주소",
@@ -127,6 +132,10 @@
"not_found": "이 할 일을 더 이상 찾을 수 없어요.",
"save_error": "할 일을 저장하지 못했어요",
"update_error": "할 일을 업데이트하지 못했어요"
},
"views": {
"schedule": "일정",
"three_days": "3일"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
"severity_deprecated": "필수",
"severity_security": "보안",
"title": "앱 업데이트"
"title": "앱 업데이트",
"verifying": "확인 중…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Saite",
"export_ics_short": ".ics",
"nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.",
"tap_to_create": "Pieskarieties +, lai izveidotu notikumu"
},
"fab": {
"event": "Notikums",
"task": "Uzdevums"
},
"form": {
"description_placeholder": "Piezīmes, darba kārtība…",
"location_placeholder": "Vieta vai adrese",
@@ -127,6 +132,10 @@
"not_found": "Šis uzdevums vairs nav pieejams.",
"save_error": "Neizdevās saglabāt uzdevumu",
"update_error": "Neizdevās atjaunināt uzdevumu"
},
"views": {
"schedule": "Grafiks",
"three_days": "3 dienas"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
"severity_deprecated": "Obligāts",
"severity_security": "Drošība",
"title": "Lietotnes atjauninājumi"
"title": "Lietotnes atjauninājumi",
"verifying": "Pārbauda…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Холбоос",
"export_ics_short": ".ics",
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
},
"fab": {
"event": "Үйл явдал",
"task": "Даалгавар"
},
"form": {
"description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…",
"location_placeholder": "Газар эсвэл хаяг",
@@ -127,6 +132,10 @@
"not_found": "Энэ даалгавар байхгүй болсон.",
"save_error": "Даалгаврыг хадгалж чадсангүй",
"update_error": "Даалгаврыг шинэчилж чадсангүй"
},
"views": {
"schedule": "Хуваарь",
"three_days": "3 өдөр"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
"severity_deprecated": "Заавал",
"severity_security": "Аюулгүй байдал",
"title": "Аппын шинэчлэл"
"title": "Аппын шинэчлэл",
"verifying": "Шалгаж байна…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Lenke",
"export_ics_short": ".ics",
"nothing_planned": "Ingenting planlagt. Trykk for å opprette.",
"tap_to_create": "Trykk på + for å opprette en"
},
"fab": {
"event": "Hendelse",
"task": "Oppgave"
},
"form": {
"description_placeholder": "Notater, agenda…",
"location_placeholder": "Sted eller adresse",
@@ -127,6 +132,10 @@
"not_found": "Denne oppgaven er ikke lenger tilgjengelig.",
"save_error": "Kunne ikke lagre oppgaven",
"update_error": "Kunne ikke oppdatere oppgaven"
},
"views": {
"schedule": "Timeplan",
"three_days": "3 dager"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
"severity_deprecated": "Påkrevd",
"severity_security": "Sikkerhet",
"title": "Appoppdateringer"
"title": "Appoppdateringer",
"verifying": "Verifiserer…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Niets gepland. Tik om aan te maken.",
"tap_to_create": "Tik op + om er een te maken"
},
"fab": {
"event": "Afspraak",
"task": "Taak"
},
"form": {
"description_placeholder": "Notities, agendapunten…",
"location_placeholder": "Plaats of adres",
@@ -127,6 +132,10 @@
"not_found": "Deze taak is niet meer beschikbaar.",
"save_error": "Taak opslaan mislukt",
"update_error": "Taak bijwerken mislukt"
},
"views": {
"schedule": "Planning",
"three_days": "3 dagen"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
"severity_deprecated": "Vereist",
"severity_security": "Beveiliging",
"title": "App-updates"
"title": "App-updates",
"verifying": "Controleren…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.",
"tap_to_create": "Stuknij +, aby utworzyć wydarzenie"
},
"fab": {
"event": "Wydarzenie",
"task": "Zadanie"
},
"form": {
"description_placeholder": "Notatki, agenda…",
"location_placeholder": "Miejsce lub adres",
@@ -127,6 +132,10 @@
"not_found": "To zadanie nie jest już dostępne.",
"save_error": "Nie udało się zapisać zadania",
"update_error": "Nie udało się zaktualizować zadania"
},
"views": {
"schedule": "Harmonogram",
"three_days": "3 dni"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
"severity_deprecated": "Wymagana",
"severity_security": "Bezpieczeństwo",
"title": "Aktualizacje aplikacji"
"title": "Aktualizacje aplikacji",
"verifying": "Weryfikowanie…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nada planejado. Toque para criar.",
"tap_to_create": "Toque em + para criar um"
},
"fab": {
"event": "Evento",
"task": "Tarefa"
},
"form": {
"description_placeholder": "Notas, pauta…",
"location_placeholder": "Local ou endereço",
@@ -127,6 +132,10 @@
"not_found": "Esta tarefa não está mais disponível.",
"save_error": "Falha ao salvar a tarefa",
"update_error": "Falha ao atualizar a tarefa"
},
"views": {
"schedule": "Programação",
"three_days": "3 dias"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
"severity_deprecated": "Obrigatória",
"severity_security": "Segurança",
"title": "Atualizações do app"
"title": "Atualizações do app",
"verifying": "Verificando…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nimic planificat. Atinge pentru a crea.",
"tap_to_create": "Atingeți + pentru a crea unul"
},
"fab": {
"event": "Eveniment",
"task": "Sarcină"
},
"form": {
"description_placeholder": "Note, agendă…",
"location_placeholder": "Loc sau adresă",
@@ -127,6 +132,10 @@
"not_found": "Această sarcină nu mai este disponibilă.",
"save_error": "Sarcina nu a putut fi salvată",
"update_error": "Sarcina nu a putut fi actualizată"
},
"views": {
"schedule": "Program",
"three_days": "3 zile"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
"severity_deprecated": "Obligatorie",
"severity_security": "Securitate",
"title": "Actualizări aplicație"
"title": "Actualizări aplicație",
"verifying": "Se verifică…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Ссылка",
"export_ics_short": ".ics",
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
"tap_to_create": "Нажмите +, чтобы создать"
},
"fab": {
"event": "Мероприятие",
"task": "Задача"
},
"form": {
"description_placeholder": "Заметки, повестка…",
"location_placeholder": "Место или адрес",
@@ -127,6 +132,10 @@
"not_found": "Эта задача больше недоступна.",
"save_error": "Не удалось сохранить задачу",
"update_error": "Не удалось обновить задачу"
},
"views": {
"schedule": "Расписание",
"three_days": "3 дня"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
"severity_deprecated": "Обязательное",
"severity_security": "Безопасность",
"title": "Обновления приложения"
"title": "Обновления приложения",
"verifying": "Проверка…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Odkaz",
"export_ics_short": ".ics",
"nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.",
"tap_to_create": "Udalosť vytvoríte klepnutím na +"
},
"fab": {
"event": "Udalosť",
"task": "Úloha"
},
"form": {
"description_placeholder": "Poznámky, program…",
"location_placeholder": "Miesto alebo adresa",
@@ -127,6 +132,10 @@
"not_found": "Táto úloha už nie je k dispozícii.",
"save_error": "Úlohu sa nepodarilo uložiť",
"update_error": "Úlohu sa nepodarilo aktualizovať"
},
"views": {
"schedule": "Program",
"three_days": "3 dni"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečenie",
"title": "Aktualizácie aplikácie"
"title": "Aktualizácie aplikácie",
"verifying": "Overovanie…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Bağlantı",
"export_ics_short": ".ics",
"nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.",
"tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun"
},
"fab": {
"event": "Etkinlik",
"task": "Görev"
},
"form": {
"description_placeholder": "Notlar, gündem…",
"location_placeholder": "Yer veya adres",
@@ -127,6 +132,10 @@
"not_found": "Bu görev artık kullanılamıyor.",
"save_error": "Görev kaydedilemedi",
"update_error": "Görev güncellenemedi"
},
"views": {
"schedule": "Program",
"three_days": "3 gün"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
"severity_deprecated": "Zorunlu",
"severity_security": "Güvenlik",
"title": "Uygulama güncellemeleri"
"title": "Uygulama güncellemeleri",
"verifying": "Doğrulanıyor…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Посилання",
"export_ics_short": ".ics",
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
"tap_to_create": "Натисніть +, щоб створити"
},
"fab": {
"event": "Подія",
"task": "Завдання"
},
"form": {
"description_placeholder": "Нотатки, порядок денний…",
"location_placeholder": "Місце або адреса",
@@ -127,6 +132,10 @@
"not_found": "Це завдання більше недоступне.",
"save_error": "Не вдалося зберегти завдання",
"update_error": "Не вдалося оновити завдання"
},
"views": {
"schedule": "Розклад",
"three_days": "3 дні"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
"severity_deprecated": "Обов'язкове",
"severity_security": "Безпека",
"title": "Оновлення застосунку"
"title": "Оновлення застосунку",
"verifying": "Перевірка…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "連結",
"export_ics_short": ".ics",
"nothing_planned": "沒有安排。輕觸即可建立。",
"tap_to_create": "點選 + 即可建立"
},
"fab": {
"event": "活動",
"task": "工作"
},
"form": {
"description_placeholder": "備註、議程…",
"location_placeholder": "地點或地址",
@@ -127,6 +132,10 @@
"not_found": "此任務已不存在。",
"save_error": "儲存任務失敗",
"update_error": "更新任務失敗"
},
"views": {
"schedule": "行程",
"three_days": "3 天"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
"severity_deprecated": "必要",
"severity_security": "安全性",
"title": "應用程式更新"
"title": "應用程式更新",
"verifying": "正在驗證…"
}
}
+11 -1
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "链接",
"export_ics_short": ".ics",
"nothing_planned": "暂无安排。点按即可创建。",
"tap_to_create": "点按 + 创建"
},
"fab": {
"event": "活动",
"task": "任务"
},
"form": {
"description_placeholder": "备注、议程…",
"location_placeholder": "地点或地址",
@@ -127,6 +132,10 @@
"not_found": "此任务已不存在。",
"save_error": "保存任务失败",
"update_error": "更新任务失败"
},
"views": {
"schedule": "日程",
"three_days": "3 天"
}
},
"common": {
@@ -1168,6 +1177,7 @@
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
"severity_deprecated": "必需",
"severity_security": "安全",
"title": "应用更新"
"title": "应用更新",
"verifying": "正在验证…"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.73",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.73",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.73",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+171
View File
@@ -0,0 +1,171 @@
// Put an uploaded build on the public TestFlight link: wait until App Store
// Connect has processed it, add it to the external beta group, set its
// "What to Test" text and submit it for beta review. Builds only reach
// external testers (and so the public link) once Apple approves them.
//
// node scripts/testflight-public.mjs <marketing version> <build number>
//
// Environment:
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.p8)
// BUNDLE_ID default org.bulwarkmail.mobile
// TESTFLIGHT_GROUP external group name (default "external")
// WHATS_NEW "What to Test" text (default: link to the GitHub release)
//
// Every step can be repeated: a build already in the group, already described
// or already submitted is left as it is, so a re-run picks up where the last
// one stopped.
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const [version, buildNumber] = process.argv.slice(2);
if (!version || !buildNumber) {
console.error('usage: node scripts/testflight-public.mjs <marketing version> <build number>');
process.exit(2);
}
const keyId = process.env.ASC_KEY_ID;
const issuer = process.env.ASC_ISSUER_ID;
if (!keyId || !issuer) {
console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.');
process.exit(2);
}
const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`);
const privateKey = fs.readFileSync(keyPath, 'utf8');
const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile';
const groupName = process.env.TESTFLIGHT_GROUP || 'external';
const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native';
const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`;
// Processing usually takes 5-30 minutes; give up well inside the job timeout.
const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000;
const POLL_MS = 30 * 1000;
function token() {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' });
const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' });
const sig = crypto
.sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' })
.toString('base64url');
return `${head}.${body}.${sig}`;
}
async function api(method, p, body) {
const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, {
method,
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, ok: res.ok, json };
}
function fail(step, r) {
const e = r.json?.errors?.[0];
console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`);
if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
console.error('An Apple agreement needs accepting in App Store Connect > Business.');
}
process.exit(1);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── The app and the group ──────────────────────────────────────
const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`);
if (!apps.ok) fail('Looking up the app', apps);
const app = apps.json.data[0];
if (!app) {
console.error(`No app with bundle ID ${bundleId} in App Store Connect.`);
process.exit(1);
}
const groups = await api(
'GET',
`/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`,
);
if (!groups.ok) fail('Looking up the beta group', groups);
const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName);
if (!group) {
console.error(`No external beta group named "${groupName}".`);
process.exit(1);
}
// ── Wait for the build ─────────────────────────────────────────
const query =
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
const started = Date.now();
let build;
for (;;) {
const r = await api('GET', query);
if (!r.ok) fail('Looking up the build', r);
build = r.json.data[0];
const state = build?.attributes.processingState;
if (state === 'VALID') break;
if (state === 'FAILED' || state === 'INVALID') {
console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`);
process.exit(1);
}
if (Date.now() - started > PROCESSING_TIMEOUT_MS) {
console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`);
process.exit(1);
}
console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`);
await sleep(POLL_MS);
}
console.log(`Build ${version} (${buildNumber}) is processed.`);
// ── Add it to the group ────────────────────────────────────────
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
data: [{ type: 'builds', id: build.id }],
});
if (!add.ok) fail(`Adding the build to "${groupName}"`, add);
console.log(`Added to the "${groupName}" group.`);
// ── "What to Test" ─────────────────────────────────────────────
const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`);
if (!locs.ok) fail('Reading the build notes', locs);
const en = locs.json.data.find((l) => l.attributes.locale === 'en-US');
const notes = en
? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, {
data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } },
})
: await api('POST', '/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew },
relationships: { build: { data: { type: 'builds', id: build.id } } },
},
});
if (!notes.ok) fail('Setting "What to Test"', notes);
// ── Beta review ────────────────────────────────────────────────
const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
if (!detail.ok) fail('Reading the build state', detail);
const external = detail.json.data.attributes.externalBuildState;
if (external === 'MISSING_EXPORT_COMPLIANCE') {
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
process.exit(1);
}
if (external === 'READY_FOR_BETA_SUBMISSION') {
const submit = await api('POST', '/v1/betaAppReviewSubmissions', {
data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } },
});
if (!submit.ok) fail('Submitting for beta review', submit);
console.log('Submitted for beta review.');
} else {
console.log(`Not submitted again: the build is ${external}.`);
}
const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null;
console.log(
link
? `Testers with ${link} get it once Apple approves the build.`
: `The "${groupName}" group has no public link enabled.`,
);
+133 -57
View File
@@ -9,8 +9,8 @@ import {
type LayoutChangeEvent,
type ViewToken,
} from 'react-native';
import { format, type Locale } from 'date-fns';
import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone';
import { endOfWeek, format, startOfWeek, type Locale } from 'date-fns';
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -47,25 +47,28 @@ interface AgendaViewProps {
/** Reports the first day in view as the user scrolls. */
onVisibleDateChange?: (date: Date) => void;
timeFormat?: TimeFormat;
weekStartsOn?: 0 | 1 | 6;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
/** Tapping an empty "today" row creates an event on that day. */
onCreateAt?: (date: Date) => void;
}
interface DaySection extends AgendaDay {
key: string;
title: string;
/** Set on the first day of a month in the list: the month's name. */
monthTitle: string | null;
/** Set on the first day of a week in the list: the week's range. */
weekTitle: string | null;
}
function formatDayHeader(
date: Date,
t: (key: string, fallback?: string) => string,
locale: Locale,
): string {
// Today and tomorrow on a clock in the calendar's time zone.
if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today');
if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow');
return format(date, 'EEEE, MMM d', { locale });
// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months.
function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string {
const start = startOfWeek(date, { weekStartsOn });
const end = endOfWeek(date, { weekStartsOn });
const sameMonth = start.getMonth() === end.getMonth();
return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
}
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
@@ -92,8 +95,10 @@ export function AgendaView({
isLoading = false,
onVisibleDateChange,
timeFormat,
weekStartsOn = 1,
currentUserEmails,
onSelectEvent,
onCreateAt,
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -104,15 +109,21 @@ export function AgendaView({
[eventsByDay, events],
);
const sections = React.useMemo<DaySection[]>(
() =>
buildAgendaDays(index, loaded, displayNow()).map((day) => ({
...day,
key: dayKey(day.date),
title: formatDayHeader(day.date, t, locale),
})),
[index, loaded, t, locale],
);
// A heading where a month starts and a separator where a week starts,
// then the days.
const sections = React.useMemo<DaySection[]>(() => {
let lastMonth = '';
let lastWeek = '';
return buildAgendaDays(index, loaded, displayNow()).map((day) => {
const month = format(day.date, 'yyyy-MM');
const week = dayKey(startOfWeek(day.date, { weekStartsOn }));
const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null;
const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null;
lastMonth = month;
lastWeek = week;
return { ...day, key: dayKey(day.date), monthTitle, weekTitle };
});
}, [index, loaded, weekStartsOn, locale]);
const listRef = React.useRef<SectionList<CalendarEvent, DaySection>>(null);
const sectionsRef = React.useRef(sections);
@@ -297,7 +308,7 @@ export function AgendaView({
ref={listRef}
sections={sections}
keyExtractor={(item, i) => `${item.id}:${i}`}
stickySectionHeadersEnabled
stickySectionHeadersEnabled={false}
// Earlier days are inserted above what the user is reading without
// moving it.
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
@@ -314,32 +325,42 @@ export function AgendaView({
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={11}
renderSectionHeader={({ section }) => {
const today = isDisplayToday(section.date);
return (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
{section.title}
</Text>
<Text style={styles.sectionSub}>{format(section.date, 'PP', { locale })}</Text>
renderSectionHeader={({ section }) =>
section.monthTitle || section.weekTitle ? (
<View>
{section.monthTitle && <Text style={styles.monthTitle}>{section.monthTitle}</Text>}
{section.weekTitle && <Text style={styles.weekTitle}>{section.weekTitle}</Text>}
</View>
) : null
}
renderItem={({ item, index: i, section }) => (
<View style={[styles.row, i === section.data.length - 1 && styles.rowLast]}>
<DateColumn date={section.date} show={i === 0} locale={locale} styles={styles} />
<View style={styles.eventCol}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
day={section.date}
/>
</View>
);
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
</View>
)}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={styles.emptyDayWrap}>
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
<View style={[styles.row, styles.rowLast]}>
<DateColumn date={section.date} show locale={locale} styles={styles} />
<Pressable
style={styles.emptyDay}
onPress={onCreateAt ? () => onCreateAt(section.date) : undefined}
accessibilityRole={onCreateAt ? 'button' : undefined}
>
<Text style={styles.emptyDayText}>
{t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')}
</Text>
</Pressable>
</View>
) : null
}
@@ -347,24 +368,79 @@ export function AgendaView({
);
}
type AgendaStyles = ReturnType<typeof makeStyles>;
// The day on the left of its first row: weekday over the day number, today
// in a filled circle.
function DateColumn({
date,
show,
locale,
styles,
}: {
date: Date;
show: boolean;
locale: Locale;
styles: AgendaStyles;
}) {
if (!show) return <View style={styles.dateCol} />;
const today = isDisplayToday(date);
return (
<View style={styles.dateCol}>
<Text style={[styles.dateWeekday, today && styles.dateWeekdayToday]}>
{format(date, 'EEE', { locale })}
</Text>
<View style={[styles.dateNumber, today && styles.dateNumberToday]}>
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
{format(date, 'd')}
</Text>
</View>
</View>
);
}
const DATE_COL_WIDTH = 52;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sectionHeader: {
flexDirection: 'row',
alignItems: 'baseline',
gap: spacing.sm,
monthTitle: {
...typography.h2,
fontWeight: '500',
color: c.text,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: spacing.xxl,
paddingBottom: spacing.sm,
},
sectionTitle: { ...typography.bodyMedium, color: c.text },
sectionTitleToday: { color: c.primary },
sectionSub: { ...typography.caption, color: c.textMuted },
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
weekTitle: {
...typography.caption,
color: c.textMuted,
paddingLeft: DATE_COL_WIDTH + spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingRight: spacing.lg,
paddingLeft: spacing.sm,
},
rowLast: { paddingBottom: spacing.sm },
dateCol: { width: DATE_COL_WIDTH, alignItems: 'center' },
dateWeekday: { ...typography.captionMedium, color: c.textSecondary },
dateWeekdayToday: { color: c.primary },
dateNumber: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
dateNumberToday: { backgroundColor: c.primary },
dateNumberText: { fontSize: 20, lineHeight: 26, fontWeight: '500', color: c.text },
dateNumberTextToday: { color: c.primaryForeground },
eventCol: { flex: 1, marginLeft: spacing.md },
emptyDay: { flex: 1, marginLeft: spacing.md, paddingTop: 18 },
emptyDayText: { ...typography.body, color: c.textSecondary },
emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' },
edge: {
paddingHorizontal: spacing.lg,
+153
View File
@@ -0,0 +1,153 @@
import React from 'react';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import { CalendarPlus, CircleCheck, Plus } from 'lucide-react-native';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { useLocaleStore } from '../../stores/locale-store';
interface CalendarFabProps {
onNewEvent: () => void;
/** With tasks turned on, the button first offers "Event" and "Task". */
onNewTask?: () => void;
}
/**
* The floating create button in the corner of the calendar, styled like the
* mail list's compose button. Without tasks it creates an event at once;
* with tasks it opens a small menu over a scrim.
*/
export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [open, setOpen] = React.useState(false);
const progress = React.useRef(new Animated.Value(0)).current;
const duration = useAnimDuration(160);
React.useEffect(() => {
Animated.timing(progress, {
toValue: open ? 1 : 0,
duration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [open, progress, duration]);
const pick = (action: () => void) => {
setOpen(false);
action();
};
const onMainPress = () => {
if (!onNewTask) {
onNewEvent();
return;
}
if (open) pick(onNewEvent);
else setOpen(true);
};
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '90deg'] });
const lift = progress.interpolate({ inputRange: [0, 1], outputRange: [12, 0] });
return (
<>
{open && (
<Animated.View style={[styles.scrim, { opacity: progress }]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
/>
</Animated.View>
)}
<View style={styles.anchor} pointerEvents="box-none">
{open && onNewTask && (
<Animated.View style={[styles.option, { opacity: progress, transform: [{ translateY: lift }] }]}>
<Text style={styles.optionLabel}>{t('calendar.fab.task', 'Task')}</Text>
<Pressable
style={styles.miniFab}
onPress={() => pick(onNewTask)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_task', 'New task')}
>
<CircleCheck size={20} color={c.text} />
</Pressable>
</Animated.View>
)}
<View style={styles.mainRow}>
{open && (
<Animated.Text style={[styles.optionLabel, { opacity: progress }]}>
{t('calendar.fab.event', 'Event')}
</Animated.Text>
)}
<Pressable
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
onPress={onMainPress}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
{open ? (
<CalendarPlus size={24} color={c.background} />
) : (
<Animated.View style={{ transform: [{ rotate }] }}>
<Plus size={26} color={c.background} />
</Animated.View>
)}
</Pressable>
</View>
</View>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
scrim: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.55)',
zIndex: 20,
},
anchor: {
position: 'absolute',
right: spacing.lg,
bottom: spacing.lg,
alignItems: 'flex-end',
gap: spacing.lg,
zIndex: 21,
},
option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginRight: 8 },
mainRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
optionLabel: { ...typography.baseMedium, color: '#ffffff' },
miniFab: {
width: 40,
height: 40,
borderRadius: radius.full,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
elevation: 3,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
// Same as the compose button on the mail list (EmailListScreen).
fab: {
width: componentSizes.fab,
height: componentSizes.fab,
borderRadius: radius.full,
backgroundColor: c.text,
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 3 },
},
fabPressed: { opacity: 0.85 },
});
}
@@ -12,7 +12,8 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
type LucideIcon,
} from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -23,8 +24,19 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
key: string;
label: string;
Icon: LucideIcon;
}
interface CalendarSidebarDrawerProps {
visible: boolean;
views?: DrawerViewOption[];
activeView?: string;
onSelectView?: (key: string) => void;
onRefresh?: () => void;
calendars: Calendar[];
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
@@ -48,6 +60,10 @@ interface CalendarSidebarDrawerProps {
export function CalendarSidebarDrawer({
visible,
views,
activeView,
onSelectView,
onRefresh,
calendars,
hiddenCalendarIds,
onToggle,
@@ -161,10 +177,44 @@ export function CalendarSidebarDrawer({
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
<Text style={styles.headerTitle}>{t('calendar.title', 'Calendar')}</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{views && views.length > 0 && (
<View style={styles.viewsSection}>
{views.map(({ key, label, Icon }) => {
const active = key === activeView;
return (
<Pressable
key={key}
onPress={() => onSelectView?.(key)}
style={({ pressed }) => [
styles.viewRow,
active && styles.viewRowActive,
pressed && !active && styles.rowPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.text : c.textSecondary} />
<Text style={[styles.viewText, active && styles.viewTextActive]}>{label}</Text>
</Pressable>
);
})}
{onRefresh && (
<Pressable
onPress={onRefresh}
style={({ pressed }) => [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<RefreshCw size={16} color={c.textSecondary} />
<Text style={styles.viewText}>{t('common.refresh', 'Refresh')}</Text>
</Pressable>
)}
</View>
)}
{calendars.length === 0 && (
<Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
)}
@@ -431,6 +481,27 @@ function makeStyles(c: ThemePalette) {
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
viewsSection: {
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
// Same rows as the mail drawer's folders (SidebarDrawer).
viewRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
minHeight: 44,
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
viewRowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
refreshRow: { marginTop: spacing.xs },
viewText: { ...typography.body, color: c.text },
viewTextActive: { ...typography.bodySemibold },
scrollContent: { paddingBottom: spacing.lg },
empty: {
+9 -4
View File
@@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
isTimedEventFullDayOnDate,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
@@ -22,6 +23,8 @@ interface EventCardProps {
currentUserEmails?: string[];
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
/** The day the row is listed under: a timed event filling it reads as all day. */
day?: Date;
}
function participantCount(event: CalendarEvent): number {
@@ -33,12 +36,14 @@ function participantCount(event: CalendarEvent): number {
* colour before the title, no card and no bar. Declined and cancelled events
* get a hollow dot and a struck-through, muted title.
*/
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const color = getEventColor(event, calendars);
const { start, end, allDay } = eventTimeRange(event);
const range = eventTimeRange(event);
const { start, end } = range;
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
const fmt = timePattern(timeFormat);
const time = allDay
? t('calendar.events.all_day', 'All day')
@@ -59,7 +64,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{event.showWithoutTime && (
{allDay && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
@@ -70,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
</View>
)}
</View>
{!event.showWithoutTime && (
{!allDay && (
<View style={styles.detailRow}>
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
+49 -4
View File
@@ -4,14 +4,19 @@ import {
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { openExternalUrl } from '../../lib/open-url';
import { toast } from '../../stores/toast-store';
import { ToastHost } from '../ToastHost';
import { splitTextLinks } from '../../lib/linkify-text';
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
AlignLeft,
@@ -184,8 +189,11 @@ export function EventDetailSheet({
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event, { resolveName: resolveContactName });
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const location = primaryLocationName(event);
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = findMeetingLink(event);
const videoUri = meeting?.uri;
const isCancelled = event.status === 'cancelled';
// Can the signed-in user RSVP? Only when they appear as a non-organizer
@@ -207,6 +215,23 @@ export function EventDetailSheet({
);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const openLocation = () => {
if (!location) return;
const action = locationAction(location, meeting);
if (action.kind === 'url') {
void openExternalUrl(action.uri, { confirm: true });
} else {
void openExternalUrl(mapsUrl(action.query, Platform.OS));
}
};
const copyLocation = () => {
if (!location) return;
void Clipboard.setStringAsync(location).then(() => {
toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard'));
}).catch(() => {});
};
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !myParticipantId || rsvpBusy) return;
setRsvpBusy(true);
@@ -263,7 +288,21 @@ export function EventDetailSheet({
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<MapPin size={16} color={c.textMuted} />
</View>
<Text
style={[styles.detailText, styles.detailLink]}
numberOfLines={3}
onPress={openLocation}
onLongPress={copyLocation}
accessibilityRole="link"
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
>
{location}
</Text>
</View>
) : null}
{videoUri ? (
<View style={styles.detailRow}>
@@ -274,7 +313,10 @@ export function EventDetailSheet({
style={styles.joinBtn}
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
>
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
<Text style={styles.joinBtnText}>
{t('calendar.detail.open_link', 'Open link')}
{meeting?.provider ? ` · ${meeting.provider}` : ''}
</Text>
</Pressable>
</View>
) : null}
@@ -414,6 +456,9 @@ export function EventDetailSheet({
)}
</SafeAreaView>
</Animated.View>
{/* The screen's own toast host sits under this Modal; this one shows
the copy confirmation above the sheet. */}
<ToastHost />
</Modal>
);
}
+2
View File
@@ -99,9 +99,11 @@ interface EventModalProps {
onClose: () => void;
}
// A time already on the hour or half hour (a tapped slot) stays as it is.
function nextHalfHour(d: Date): Date {
const date = new Date(d);
const min = date.getMinutes();
if (min % 30 === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0) return date;
const add = min < 30 ? 30 - min : 60 - min;
date.setMinutes(date.getMinutes() + add, 0, 0);
return date;
+125
View File
@@ -0,0 +1,125 @@
import React from 'react';
import {
FlatList,
StyleSheet,
View,
useWindowDimensions,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { addMonths, startOfMonth } from 'date-fns';
import type { Calendar } from '../../api/types';
import type { ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import type { EventDayIndex } from '../../lib/calendar-utils';
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
type WeekStart = 0 | 1 | 6;
/** Months either side of the one the panel opens on. */
const MONTHS_EACH_SIDE = 24;
/** Six week rows plus the weekday initials. */
const PANEL_HEIGHT = MONTH_ROW_HEIGHT * 6 + 22;
interface MonthDropdownProps {
/** The month the panel opens on. */
month: Date;
selectedDate: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
weekStartsOn: WeekStart;
/** Reports the month swiped to, so the title can follow it. */
onMonthChange: (month: Date) => void;
onSelectDate: (date: Date) => void;
}
/**
* The month that drops down under the calendar title: swipe sideways
* between months, tap a day to go there.
*/
export function MonthDropdown({
month,
selectedDate,
eventsByDay,
calendars,
weekStartsOn,
onMonthChange,
onSelectDate,
}: MonthDropdownProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width } = useWindowDimensions();
// The months are fixed while the panel is open; it remounts on each open.
const [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i));
});
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
const handleMomentumEnd = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(e.nativeEvent.contentOffset.x / width);
if (index === lastIndexRef.current || !months[index]) return;
lastIndexRef.current = index;
onMonthChange(months[index]);
},
[width, months, onMonthChange],
);
const renderItem = React.useCallback(
({ item }: ListRenderItemInfo<Date>) => (
<View style={{ width, height: PANEL_HEIGHT }}>
<MonthView
currentDate={item}
selectedDate={selectedDate}
events={[]}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</View>
),
[width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
(_: ArrayLike<Date> | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }),
[width],
);
return (
<View style={styles.panel}>
<FlatList
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={MONTHS_EACH_SIDE}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: PANEL_HEIGHT }}
/>
</View>
);
}
function monthKey(month: Date): string {
return `${month.getFullYear()}-${month.getMonth()}`;
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
panel: {
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: 4,
},
});
}
+16 -4
View File
@@ -2,6 +2,7 @@ import React from 'react';
import {
FlatList,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
@@ -26,8 +27,7 @@ import {
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
import {
MONTH_ROW_HEIGHT,
MONTH_ROW_HEIGHT_CHIPS,
MONTH_ROW_HEIGHT_FULL_MIN,
MonthWeekRow,
MonthWeekdayHeader,
useMonthStyles,
@@ -117,7 +117,14 @@ function MonthScrollViewInner({
}, [window]);
const weeksRef = React.useRef(weeks);
weeksRef.current = weeks;
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
// Six weeks fill the space under the weekday initials, like Google
// Calendar's month; rows wait for that space to be measured.
const [listHeight, setListHeight] = React.useState(0);
const handleListLayout = React.useCallback((e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;
if (height > 0) setListHeight(height);
}, []);
const rowHeight = Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(listHeight / VISIBLE_ROWS));
const viewportHeight = rowHeight * VISIBLE_ROWS;
// The month in focus: set by navigation, then by what scrolls under the
@@ -196,6 +203,7 @@ function MonthScrollViewInner({
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths
variant="full"
height={rowHeight}
locale={locale}
styles={styles}
@@ -232,8 +240,10 @@ function MonthScrollViewInner({
);
return (
<View style={styles.grid}>
<View style={styles.fullGrid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
<View style={styles.fullRows} onLayout={handleListLayout}>
{listHeight > 0 && (
<FlatList
key={rowHeight}
ref={listRef}
@@ -261,6 +271,8 @@ function MonthScrollViewInner({
maxToRenderPerBatch={VISIBLE_ROWS}
windowSize={5}
/>
)}
</View>
</View>
);
}
+197 -2
View File
@@ -19,6 +19,7 @@ import {
eventsOnDayFromIndex,
getEventColor,
getEventStartDate,
isTimedEventFullDayOnDate,
timePattern,
type EventDayIndex,
type TimeFormat,
@@ -35,6 +36,8 @@ type WeekStart = 0 | 1 | 6;
// scrolling month uses them as fixed row heights.
export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
// Smallest week row of the full-screen month grid.
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
// Corner radius of event bars (repos/branding/APP.md).
const EVENT_RADIUS = 2;
@@ -48,6 +51,8 @@ interface MonthViewProps {
showWeekNumbers?: boolean;
// Render compact event chips (title + start time) instead of dots (#666).
showTimeInMonthView?: boolean;
/** Fill the available height with the full month grid. */
fill?: boolean;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
@@ -113,6 +118,12 @@ export interface MonthWeekRowProps {
labelMonths?: boolean;
/** Fixed row height (continuous scrolling); natural height otherwise. */
height?: number;
/**
* 'full': the month fills the screen, with grid
* lines and an event chip per event. 'compact': numbers with event dots
* (the month under the title).
*/
variant?: 'compact' | 'full';
locale: Locale;
styles: MonthStyles;
onSelectDate: (date: Date) => void;
@@ -133,12 +144,36 @@ function MonthWeekRowInner({
currentUserEmails,
labelMonths = false,
height,
variant = 'compact',
locale,
styles,
onSelectDate,
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
if (variant === 'full') {
return (
<FullWeekRow
days={days}
activeMask={activeMask}
todayIndex={todayIndex}
index={index}
calendars={calendars}
weekStartsOn={weekStartsOn}
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths={labelMonths}
height={height ?? MONTH_ROW_HEIGHT_FULL_MIN}
locale={locale}
styles={styles}
colors={c}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
);
}
return (
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
{showWeekNumbers && (
@@ -243,6 +278,113 @@ function MonthWeekRowInner({
export const MonthWeekRow = React.memo(MonthWeekRowInner);
/** Day number area at the top of a full month cell. */
const FULL_NUMBER_HEIGHT = 24;
/** One event chip in a full month cell, with its gap. */
const FULL_CHIP_HEIGHT = 16;
const FULL_CHIP_GAP = 2;
/** Event chips that fit a full month cell of `height`, keeping a line for "+N" when needed. */
export function fullCellChipCount(height: number, eventCount: number): number {
const room = Math.max(0, Math.floor((height - FULL_NUMBER_HEIGHT - 2) / (FULL_CHIP_HEIGHT + FULL_CHIP_GAP)));
if (eventCount <= room) return eventCount;
return Math.max(0, room - 1);
}
// A week of the full-screen month grid: thin cell lines, the day number
// small at the top (today in a filled circle), then one filled chip per
// event and "+N" for those that don't fit.
function FullWeekRow({
days,
activeMask,
todayIndex,
index,
calendars,
weekStartsOn,
showWeekNumbers,
showTimeInMonthView,
timeFormat,
currentUserEmails,
labelMonths,
height,
locale,
styles,
colors: c,
onSelectDate,
onLongPressDate,
}: Omit<MonthWeekRowProps, 'selectedIndex' | 'variant'> & {
height: number;
colors: ThemePalette;
}) {
return (
<View style={[styles.fullWeekRow, { height }]}>
{showWeekNumbers && (
<Text style={[styles.weekNumberCell, styles.fullWeekNumber]}>{weekNumberFor(days[0], weekStartsOn)}</Text>
)}
{days.map((d, i) => {
const sameMonth = (activeMask & (1 << i)) !== 0;
const today = i === todayIndex;
const dayEvents = eventsOnDayFromIndex(index, d);
const shown = fullCellChipCount(height, dayEvents.length);
const overflow = dayEvents.length - shown;
const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd');
return (
<Pressable
key={i}
style={({ pressed }) => [styles.fullCell, i === 0 && styles.fullCellFirst, pressed && styles.fullCellPressed]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
accessibilityRole="button"
accessibilityLabel={format(d, 'PPPP', { locale })}
>
<View style={styles.fullNumberWrap}>
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
<Text
numberOfLines={1}
style={[
styles.fullNumberText,
!sameMonth && styles.dayTextMuted,
today && styles.fullNumberTextToday,
]}
>
{label}
</Text>
</View>
</View>
{dayEvents.slice(0, shown).map((event, idx) => {
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
// No time on events that fill the whole day.
const time = showTimeInMonthView && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
: '';
return (
<View
key={`${event.id}-${idx}`}
style={[
styles.fullChip,
{ backgroundColor: colors.fill },
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
]}
>
<Text
style={[styles.fullChipText, { color: colors.text }, inactive && styles.chipTextInactive]}
numberOfLines={1}
ellipsizeMode="clip"
>
{time}{event.title || ''}
</Text>
</View>
);
})}
{overflow > 0 && <Text style={styles.fullOverflow}>+{overflow}</Text>}
</Pressable>
);
})}
</View>
);
}
function MonthViewInner({
currentDate,
selectedDate,
@@ -252,12 +394,14 @@ function MonthViewInner({
weekStartsOn = 0,
showWeekNumbers = false,
showTimeInMonthView = false,
fill = false,
timeFormat,
currentUserEmails,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const styles = useMonthStyles();
const [gridHeight, setGridHeight] = React.useState(0);
const { locale } = useCalendarLocale();
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
@@ -280,12 +424,22 @@ function MonthViewInner({
// Today on a clock in the calendar's time zone.
const today = displayNow();
const fullRowHeight = fill && gridHeight > 0
? Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(gridHeight / rows.length))
: undefined;
return (
<View style={styles.grid}>
<View style={fill ? styles.fullGrid : styles.grid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
{rows.map((days) => (
<View
style={fill && styles.fullRows}
onLayout={fill ? (e) => setGridHeight(e.nativeEvent.layout.height) : undefined}
>
{(!fill || fullRowHeight !== undefined) && rows.map((days) => (
<MonthWeekRow
key={days[0].toISOString()}
variant={fill ? 'full' : 'compact'}
height={fullRowHeight}
days={days}
activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)}
@@ -303,6 +457,7 @@ function MonthViewInner({
onLongPressDate={onLongPressDate}
/>
))}
</View>
</View>
);
}
@@ -386,5 +541,45 @@ function makeStyles(c: ThemePalette) {
marginLeft: 1,
textAlign: 'center',
},
fullGrid: { flex: 1 },
fullRows: { flex: 1 },
fullWeekRow: {
flexDirection: 'row',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.border,
},
fullWeekNumber: { paddingTop: 6 },
fullCell: {
flex: 1,
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: c.border,
paddingHorizontal: 1,
overflow: 'hidden',
},
fullCellFirst: { borderLeftWidth: 0 },
fullCellPressed: { backgroundColor: c.surfaceHover },
fullNumberWrap: { height: FULL_NUMBER_HEIGHT, alignItems: 'center', justifyContent: 'center' },
fullNumber: {
minWidth: 22,
height: 22,
borderRadius: 11,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
},
fullNumberToday: { backgroundColor: c.primary },
fullNumberText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
fullNumberTextToday: { color: c.primaryForeground, fontWeight: '700' },
fullChip: {
height: FULL_CHIP_HEIGHT,
marginBottom: FULL_CHIP_GAP,
borderRadius: EVENT_RADIUS,
paddingHorizontal: 3,
justifyContent: 'center',
},
// 9px text on the phone's month bars (repos/branding/APP.md).
fullChipText: { fontSize: 9, lineHeight: 12, fontWeight: '500' },
fullOverflow: { fontSize: 10, lineHeight: 13, fontWeight: '500', color: c.textSecondary, paddingHorizontal: 3 },
});
}
+5 -2
View File
@@ -52,6 +52,8 @@ interface TasksSheetProps {
timeFormat?: TimeFormat;
// Task to open in the editor when the sheet appears (from a calendar chip).
initialTaskId?: string | null;
// Open straight on an empty editor (the calendar's "new task" button).
startNew?: boolean;
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | void;
@@ -94,6 +96,7 @@ export function TasksSheet({
calendars,
timeFormat,
initialTaskId,
startNew = false,
onClose,
onCreate,
onUpdate,
@@ -132,10 +135,10 @@ export function TasksSheet({
setExpanded(true);
} else {
setEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(false);
setExpanded(startNew);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, initialTaskId]);
}, [visible, initialTaskId, startNew]);
React.useEffect(() => {
if (visible && !editor.calendarId && defaultCalendarId) {
+52 -2
View File
@@ -37,6 +37,7 @@ import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import {
buildAllDaySegments,
columnsPerScreen,
headerColumnRange,
hourAtOffset,
timeGridFocusColumn,
@@ -119,7 +120,7 @@ function TimeGridScrollViewInner({
[eventsByDay, events],
);
const perScreen = mode === 'week' ? 7 : 1;
const perScreen = columnsPerScreen(mode);
const { width: screenWidth } = useWindowDimensions();
const [rootWidth, setRootWidth] = React.useState(screenWidth);
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
@@ -288,6 +289,7 @@ function TimeGridScrollViewInner({
const handleLongPressAt = React.useCallback(
(day: Date, hour: number) => {
if (!onCreateAtTime) return;
setDraft(null);
// A display date (that hour in the calendar's zone); the editor turns
// it into the real instant when it saves (eventTimeFieldsToSave).
const date = new Date(day);
@@ -297,6 +299,14 @@ function TimeGridScrollViewInner({
[onCreateAtTime],
);
// A tap on an empty slot marks it with a "New event" block; tapping that
// block creates the event. Two taps, so a stray tap never opens the editor.
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
const handleTapAt = React.useCallback((day: Date, hour: number) => {
const key = dayKey(day);
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
}, []);
const todayKey = dayKey(displayNow());
const renderItem = React.useCallback(
({ item: day }: ListRenderItemInfo<Date>) => (
@@ -312,11 +322,13 @@ function TimeGridScrollViewInner({
styles={styles}
onSelectEvent={onSelectEvent}
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
onTapAt={onCreateAtTime ? handleTapAt : undefined}
draftHour={draft && draft.key === dayKey(day) ? draft.hour : -1}
/>
),
[
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
onSelectEvent, onCreateAtTime, handleLongPressAt,
onSelectEvent, onCreateAtTime, handleLongPressAt, handleTapAt, draft,
],
);
@@ -442,6 +454,7 @@ function TimeGridScrollViewInner({
data={days}
keyExtractor={dayKey}
renderItem={renderItem}
extraData={draft}
getItemLayout={getItemLayout}
initialScrollIndex={initialIndexRef.current.index}
// Days added at the start keep the columns on screen in place.
@@ -534,6 +547,8 @@ const DayColumn = React.memo(function DayColumn({
styles,
onSelectEvent,
onLongPressAt,
onTapAt,
draftHour,
}: {
day: Date;
layouts: TimedEventLayout[];
@@ -547,6 +562,9 @@ const DayColumn = React.memo(function DayColumn({
styles: GridStyles;
onSelectEvent?: (event: CalendarEvent) => void;
onLongPressAt?: (day: Date, hour: number) => void;
onTapAt?: (day: Date, hour: number) => void;
/** Hour of the "New event" block in this column, or -1. */
draftHour: number;
}) {
const { t } = useCalendarLocale();
const handleLongPress = React.useCallback(
@@ -555,9 +573,16 @@ const DayColumn = React.memo(function DayColumn({
},
[onLongPressAt, day],
);
const handlePress = React.useCallback(
(e: GestureResponderEvent) => {
onTapAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
},
[onTapAt, day],
);
return (
<Pressable
style={[styles.dayCol, { width }]}
onPress={onTapAt ? handlePress : undefined}
onLongPress={onLongPressAt ? handleLongPress : undefined}
>
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
@@ -584,6 +609,18 @@ const DayColumn = React.memo(function DayColumn({
/>
);
})}
{draftHour >= 0 && (
<Pressable
style={[styles.draft, { top: draftHour * HOUR_HEIGHT, height: HOUR_HEIGHT - 1 }]}
onPress={() => onLongPressAt?.(day, draftHour)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Text style={styles.draftText} numberOfLines={1}>
{t('calendar.events.new_event', 'New event')}
</Text>
</Pressable>
)}
{nowMinutes >= 0 && (
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
<View style={styles.nowDot} />
@@ -683,5 +720,18 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
draft: {
position: 'absolute',
left: 1,
right: 2,
borderRadius: 2,
borderWidth: 1,
borderColor: c.primary,
backgroundColor: c.primaryBg,
paddingHorizontal: 4,
paddingVertical: 2,
zIndex: 2,
},
draftText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
});
}
+5 -1
View File
@@ -29,6 +29,8 @@ interface Props {
onMoreOptions: (draft: string) => void;
/** Reflect `$answered` in the caller's cache. */
onSent?: (email: Email) => void;
onFocus?: () => void;
onBlur?: () => void;
}
/**
@@ -36,7 +38,7 @@ interface Props {
* to the sender with the original quoted, sent through the identity that
* received the message. "More options" hands the text to the full composer.
*/
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) {
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -147,6 +149,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
<TextInput
value={text}
onChangeText={setText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.textMuted}
style={styles.input}
+9 -4
View File
@@ -32,6 +32,7 @@ function installButtonLabel(
): string {
if (!installing) return t('updates.install', 'Install');
if (progress?.phase === 'installing') return t('updates.installing', 'Installing…');
if (progress?.phase === 'verifying') return t('updates.verifying', 'Verifying…');
if (progress?.progress != null) {
return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) });
}
@@ -213,10 +214,12 @@ export function UpdatesSettings() {
style={[
styles.progressFill,
{
// The download is complete once it is verifying, so the
// bar stays full instead of restarting at 0.
width:
installProgress.phase === 'installing'
? '100%'
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
installProgress.phase === 'downloading'
? `${Math.round((installProgress.progress ?? 0) * 100)}%`
: '100%',
},
]}
/>
@@ -224,7 +227,9 @@ export function UpdatesSettings() {
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? t('updates.opening_installer', 'Opening installer…')
: formatProgressLabel(installProgress, t)}
: installProgress.phase === 'verifying'
? t('updates.verifying', 'Verifying…')
: formatProgressLabel(installProgress, t)}
</Text>
</View>
) : null}
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
findMeetingLink,
isMeetingLabel,
locationAction,
mapsUrl,
meetingProviderOf,
primaryLocationName,
unwrapSafeLink,
} from '../event-links';
import { isSafeExternalUrl } from '../open-url';
/**
* A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves
* the join URL in the description, next to help and dial-in links. The sheet
* must still offer a single "Join" button, and a real address must open the
* maps app.
*/
const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d';
const TEAMS_DESCRIPTION = [
'________________________________________________________________________________',
'Réunion Microsoft Teams',
'Rejoindre sur votre ordinateur, application mobile ou appareil de salle',
`Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`,
'ID de la réunion : 319 560 123 45',
'Code secret : CTYa9',
'Télécharger Teams<https://www.microsoft.com/fr-fr/microsoft-teams/download-app> | Rejoindre sur le web<https://www.microsoft.com/microsoft-teams/join-a-meeting>',
'En savoir plus<https://aka.ms/JoinTeamsMeeting> | Options de réunion<https://teams.microsoft.com/meetingOptions/?organizerId=1>',
].join('\n');
describe('findMeetingLink', () => {
it('prefers the structured virtual location', () => {
expect(findMeetingLink({
virtualLocations: { v: { uri: 'https://zoom.us/j/123' } },
description: TEAMS_DESCRIPTION,
})).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false });
});
it('digs the Teams join URL out of the description, skipping help links', () => {
expect(findMeetingLink({
locations: { l: { name: 'Réunion Microsoft Teams' } },
description: TEAMS_DESCRIPTION,
})).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true });
});
it('recognises the short Teams, Meet and Zoom forms', () => {
expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams');
expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij');
expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom');
});
it('takes a meeting URL used as the location', () => {
expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup');
});
it('unwraps Outlook Safe Links', () => {
const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`;
expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN);
});
it('ignores ordinary links', () => {
expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull();
expect(findMeetingLink({})).toBeNull();
});
it('falls back to an openable non-web virtual location, never a script one', () => {
expect(findMeetingLink({ virtualLocations: { v: { uri: 'tel:+33123456789' } } }))
.toEqual({ uri: 'tel:+33123456789', derived: false });
expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull();
expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull();
});
// openExternalUrl refuses these schemes, so offering one gave a Join button
// and a location tap that did nothing.
it('never offers an app-scheme link the app cannot open', () => {
for (const uri of ['msteams:/l/meetup-join/19%3a1', 'zoommtg://zoom.us/join?confno=1', 'sip:room@example.com']) {
expect(findMeetingLink({ virtualLocations: { v: { uri } } })).toBeNull();
expect(isSafeExternalUrl(uri)).toBe(false);
}
expect(findMeetingLink({
virtualLocations: { a: { uri: 'msteams:/l/meetup-join/19%3a1' }, b: { uri: 'tel:+33123456789' } },
})?.uri).toBe('tel:+33123456789');
});
it('recognises the Zoom web-client join path', () => {
expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom');
});
it('skips a virtual location that is not a web URL', () => {
expect(findMeetingLink({
virtualLocations: { v: { uri: 'tel:+33123456789' } },
description: 'https://meet.google.com/abc-defg-hij',
})?.provider).toBe('Google Meet');
});
});
describe('meetingProviderOf / unwrapSafeLink', () => {
it('rejects look-alike hosts', () => {
expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull();
expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull();
});
it('leaves non-wrapped URLs alone', () => {
expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y');
});
});
describe('locationAction', () => {
const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true };
it('searches the maps for a postal address', () => {
expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' });
});
it('joins the meeting when the location only names the service', () => {
expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN });
expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' });
});
it('opens a URL location', () => {
expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' });
});
it('only treats a bare service name as a meeting', () => {
for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) {
expect(isMeetingLabel(label)).toBe(true);
}
for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) {
expect(isMeetingLabel(label)).toBe(false);
}
});
});
describe('mapsUrl', () => {
it('uses the geo: intent on Android', () => {
expect(mapsUrl('12 rue de la Paix,\n75002 Paris', 'android')).toBe('geo:0,0?q=12%20rue%20de%20la%20Paix%2C%2075002%20Paris');
});
it('uses the Google Maps universal link elsewhere', () => {
expect(mapsUrl('Gare de Lyon', 'ios')).toBe('https://www.google.com/maps/search/?api=1&query=Gare%20de%20Lyon');
});
});
describe('primaryLocationName', () => {
it('trims and drops blank names', () => {
expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined();
expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'node:crypto';
const files = new Map<string, Uint8Array>();
const digest = vi.fn();
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }));
vi.mock('expo-file-system', () => ({
File: class {
constructor(private uri: string) {}
async bytes() { return files.get(this.uri) ?? new Uint8Array(); }
},
Paths: { cache: 'file:///cache' },
}));
vi.mock('expo-file-system/legacy', () => ({}));
vi.mock('expo-intent-launcher', () => ({}));
vi.mock('expo-crypto', () => ({
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
digest: (...args: unknown[]) => digest(...args),
}));
import { hashFile } from '../install-update';
const sha256Hex = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex');
describe('hashFile', () => {
const apk = new TextEncoder().encode('not really an apk');
beforeEach(() => {
files.clear();
files.set('file:///cache/app.apk', apk);
digest.mockReset();
});
it('hashes the file natively in one call', async () => {
digest.mockImplementation(async (_alg: string, data: Uint8Array) => {
const out = createHash('sha256').update(data).digest();
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength);
});
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
expect(digest).toHaveBeenCalledTimes(1);
expect(digest).toHaveBeenCalledWith('SHA-256', apk);
});
it('falls back to the JS hasher when the native digest fails', async () => {
digest.mockRejectedValue(new Error('unavailable'));
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
});
});
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as WebBrowser from 'expo-web-browser';
const { mockSecureFetch } = vi.hoisted(() => ({ mockSecureFetch: vi.fn() }));
vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch }));
import { redeemPairingCode, runWebmailHandoff } from '../oauth';
const WEBMAIL = 'https://webmail.example.com';
const SERVER = 'https://mail.example.com';
const ISSUER = 'https://id.example.net';
const TOKEN_ENDPOINT = `${ISSUER}/token`;
let config: Record<string, unknown>;
let metadata: Record<string, unknown>;
let bundle: Record<string, string>;
let stateMismatch: boolean;
function response(url: string, data: unknown): Response {
return { ok: true, status: 200, url, redirected: false, json: async () => data } as Response;
}
beforeEach(() => {
vi.resetAllMocks();
config = {
jmapServerUrl: SERVER,
oauthEnabled: true,
oauthClientId: 'stalwart',
oauthIssuerUrl: ISSUER,
};
metadata = { issuer: ISSUER, token_endpoint: TOKEN_ENDPOINT };
bundle = {
flow: 'oauth', server_url: SERVER, access_token: 'test-access',
refresh_token: 'test-refresh', client_id: 'stalwart', token_endpoint: TOKEN_ENDPOINT,
};
stateMismatch = false;
vi.mocked(WebBrowser.openAuthSessionAsync).mockImplementation(async (url) => ({
type: 'success',
url: `bulwarkmobile://auth/callback#${new URLSearchParams({
...bundle,
state: stateMismatch ? 'wrong-state' : new URL(url).searchParams.get('mobile_state')!,
})}`,
}));
mockSecureFetch.mockImplementation(async (url: string) => {
if (url === `${WEBMAIL}/api/auth/pair/redeem`) return response(url, bundle);
if (url === `${WEBMAIL}/api/config`) return response(url, config);
if (url === `${WEBMAIL}/api/auth/oauth/metadata`) return response(url, metadata);
throw new Error(`Unexpected fetch: ${url}`);
});
});
afterEach(() => vi.useRealTimers());
for (const flow of ['handoff', 'pairing'] as const) {
const login = () => flow === 'handoff'
? runWebmailHandoff(WEBMAIL)
: redeemPairingCode(WEBMAIL, 'test-pair-code');
describe(`${flow} external identity provider`, () => {
it('accepts an external token endpoint verified through the selected webmail', async () => {
await expect(login()).resolves.toMatchObject({
flow: 'oauth', serverUrl: SERVER,
tokens: { tokenEndpoint: TOKEN_ENDPOINT, clientId: 'stalwart', source: flow },
});
const discoveryCalls = mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem'));
expect(discoveryCalls.map(([url]) => url)).toEqual([
`${WEBMAIL}/api/config`, `${WEBMAIL}/api/auth/oauth/metadata`,
]);
for (const [, options] of discoveryCalls) {
expect(options).toMatchObject({
headers: { Accept: 'application/json' }, redirect: 'error', timeoutMs: 8000,
});
expect(options.signal).toBeInstanceOf(AbortSignal);
expect(options.body).toBeUndefined();
}
});
it('also accepts a configured sibling-host provider', async () => {
config.oauthIssuerUrl = metadata.issuer = 'https://id.example.com';
metadata.token_endpoint = bundle.token_endpoint = 'https://id.example.com/token';
await expect(login()).resolves.toMatchObject({ flow: 'oauth' });
});
it('keeps same-host login independent of webmail metadata', async () => {
bundle.token_endpoint = `${SERVER}/auth/token`;
await expect(login()).resolves.toMatchObject({ flow: 'oauth' });
expect(mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem'))).toHaveLength(0);
});
const mismatches: [string, () => void][] = [
['substituted token endpoint', () => { bundle.token_endpoint = 'https://attacker.test/token'; }],
['lookalike token hostname', () => { bundle.token_endpoint = 'https://id.example.net.attacker.test/token'; }],
['different token path', () => { bundle.token_endpoint = `${ISSUER}/other`; }],
['different token query', () => { bundle.token_endpoint = `${TOKEN_ENDPOINT}?other=1`; }],
['token URL with credentials', () => { bundle.token_endpoint = metadata.token_endpoint = 'https://user@id.example.net/token'; }],
['token URL with a fragment', () => { bundle.token_endpoint = metadata.token_endpoint = `${TOKEN_ENDPOINT}#other`; }],
['cleartext token endpoint', () => { bundle.token_endpoint = metadata.token_endpoint = 'http://id.example.net/token'; }],
['different client', () => { bundle.client_id = 'different-client'; }],
['different JMAP server', () => { bundle.server_url = 'https://other.example.com'; }],
['disabled OAuth', () => { config.oauthEnabled = false; }],
['non-boolean OAuth flag', () => { config.oauthEnabled = 'true'; }],
['missing issuer', () => { delete config.oauthIssuerUrl; }],
['cleartext issuer', () => { config.oauthIssuerUrl = metadata.issuer = 'http://id.example.net'; }],
['issuer mismatch', () => { metadata.issuer = 'https://other-id.example.net'; }],
['missing token endpoint', () => { delete metadata.token_endpoint; }],
];
it.each(mismatches)('rejects %s', async (_name, mutate) => {
mutate();
await expect(login()).rejects.toThrow('token endpoint is not trusted');
});
it.each(['/api/config', '/api/auth/oauth/metadata'])('fails closed when %s cannot be fetched', async (path) => {
const normal = mockSecureFetch.getMockImplementation()!;
mockSecureFetch.mockImplementation((url, options) => {
if (url.endsWith(path)) throw new Error('Network unavailable');
return normal(url, options);
});
await expect(login()).rejects.toThrow('token endpoint is not trusted');
});
const badResponses: [string, Partial<Response>][] = [
['HTTP error', { ok: false, status: 503 }],
['cross-origin redirect', { url: 'https://attacker.test/api/config', redirected: true }],
['same-origin redirect', { url: `${WEBMAIL}/different`, redirected: true }],
['redirect back to the requested URL', { url: `${WEBMAIL}/api/config`, redirected: true }],
['missing final URL (native client-certificate bridge)', { url: '' }],
['invalid JSON', { json: async () => { throw new SyntaxError('Invalid JSON'); } }],
];
it.each(badResponses)('fails closed for metadata with %s', async (_name, overrides) => {
const normal = mockSecureFetch.getMockImplementation()!;
mockSecureFetch.mockImplementation(async (url, options) => {
const result = await normal(url, options);
return url.endsWith('/pair/redeem') ? result : { ...result, ...overrides };
});
await expect(login()).rejects.toThrow('token endpoint is not trusted');
});
it('aborts metadata discovery after its deadline', async () => {
vi.useFakeTimers();
const normal = mockSecureFetch.getMockImplementation()!;
mockSecureFetch.mockImplementation((url, options) => {
if (!url.endsWith('/api/config')) return normal(url, options);
return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('Aborted'))));
});
const result = expect(login()).rejects.toThrow('token endpoint is not trusted');
await vi.advanceTimersByTimeAsync(8000);
await result;
});
});
}
it('rejects a mismatched callback state before fetching metadata', async () => {
stateMismatch = true;
await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch');
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('leaves password handoff unchanged', async () => {
bundle = { flow: 'password', server_url: SERVER, username: 'user@example.com', password: 'test-password' };
await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({
flow: 'password', serverUrl: SERVER, username: 'user@example.com', password: 'test-password',
});
expect(mockSecureFetch).not.toHaveBeenCalled();
});
@@ -16,6 +16,7 @@ import { jmapClient } from '../../api/jmap-client';
import { secureFetch } from '../client-cert';
import {
carriesNoMail,
handleNotificationAction,
matchAccountsForPush,
parseRelayPushData,
pushBackgroundTask,
@@ -185,6 +186,133 @@ describe('pushBackgroundTask notifications', () => {
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' }));
});
it('includes preview and action labels on notification', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hello', preview: 'World snippet', from: [{ name: 'Bob', email: 'bob@example.com' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({
emailId: 'm1',
title: 'Bob',
body: 'Hello',
preview: 'World snippet',
markReadLabel: expect.any(String),
deleteLabel: expect.any(String),
replyLabel: expect.any(String),
}));
});
it('handles markRead action via detached JMAP', async () => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) {
postCalls.push(JSON.parse(opts.body));
}
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: { methodResponses: [['Email/set', { updated: { m1: {} } }, '0']] }),
};
});
await handleNotificationAction({
action: 'markRead',
emailId: 'm1',
accountId: LOCAL,
});
expect(postCalls.length).toBe(1);
expect(postCalls[0].methodCalls).toEqual([
['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'],
]);
});
it('handles delete action by moving email to trash', async () => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) {
postCalls.push(JSON.parse(opts.body));
}
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [
['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
['Email/set', { updated: { m1: {} } }, '0'],
],
}),
};
});
await handleNotificationAction({
'bulwark.notification.action': 'delete',
'bulwark.notification.emailId': 'm1',
'bulwark.notification.accountId': LOCAL,
});
expect(postCalls.length).toBe(2);
expect(postCalls[1].methodCalls).toEqual([
['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'],
]);
});
it.each([
['has no trash mailbox', ['Mailbox/get', { list: [{ id: 'inbox', role: 'inbox' }] }, '0']],
['cannot list mailboxes', ['error', { type: 'accountNotFound' }, '0']],
])('never destroys from a notification when the account %s', async (_label, mailboxResponse) => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) postCalls.push(JSON.parse(opts.body));
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: { methodResponses: [mailboxResponse] }),
};
});
await handleNotificationAction({ action: 'delete', emailId: 'm1', accountId: LOCAL, jmapAccountId: 'shared' });
expect(postCalls).toHaveLength(1);
expect(postCalls[0].methodCalls[0][0]).toBe('Mailbox/get');
expect(postCalls[0].methodCalls[0][1].accountId).toBe('shared');
});
});
describe('pushes for device sync (#34)', () => {
+8
View File
@@ -41,6 +41,14 @@ describe('viewerPages', () => {
expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]);
});
it('is a single page when opened with a single emailId from notification tap', () => {
const pages = viewerPages({
emailId: 'q2', threadId: 'tq', emailIds: ['q2'],
list: LIST, threading: true,
});
expect(pages).toEqual([LIST[1]]);
});
it('is a single page for a message the list does not hold', () => {
const pages = viewerPages({
emailId: 'm9', threadId: 't9', list: LIST, threading: true,
+8 -2
View File
@@ -16,7 +16,12 @@ import type { DayRange, WeekStartsOn } from './calendar-scroll-window';
* window, scrolling sideways and snapping to whole days.
*/
export type TimeGridMode = 'week' | 'day';
export type TimeGridMode = 'week' | '3day' | 'day';
/** Day columns on one screen. */
export function columnsPerScreen(mode: TimeGridMode): number {
return mode === 'week' ? 7 : mode === '3day' ? 3 : 1;
}
/** Every day of the window, in order. */
export function windowDays(window: DayRange): Date[] {
@@ -28,7 +33,8 @@ export function windowDays(window: DayRange): Date[] {
/**
* The column navigation aligns with the start of the viewport: the focused
* week's first day in the week view, the focused day in the day view.
* week's first day in the week view, the focused day in the day and
* three-day views.
*/
export function timeGridFocusColumn(
window: DayRange,
+170
View File
@@ -0,0 +1,170 @@
/**
* What an event's place fields should do when tapped.
*
* Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives
* with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in
* the description, among dial-in numbers and help links. `findMeetingLink`
* digs it out so the sheet can offer one "Join" button without touching the
* event on the server (it is someone else's invitation).
*
* A physical address is handed to the platform's maps app instead.
*/
import { isSafeExternalUrl } from './open-url';
/** The place fields read here, loose enough for any event shape. */
export interface EventPlaces {
locations?: Record<string, { name?: string | null; description?: string | null } | null> | null;
virtualLocations?: Record<string, { uri?: string | null } | null> | null;
description?: string | null;
}
export interface MeetingLink {
uri: string;
/** Display name of the conferencing service, when recognised. */
provider?: string;
/** True when the link was found in the description, not a structured field. */
derived: boolean;
}
// Hosts whose URLs are joinable meetings. The path test keeps help pages,
// "learn more" and dial-in links on the same host out of the result.
const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [
{ name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i },
{ name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i },
{ name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i },
{ name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i },
{ name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ },
{ name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ },
{ name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ },
{ name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ },
];
const URL_RE = /https?:\/\/[^\s<>"']+/g;
const isHttpUrl = (value: string | undefined | null): value is string =>
!!value && /^https?:\/\/\S+$/i.test(value.trim());
/**
* Outlook's Safe Links and similar gateways wrap the real URL in a query
* parameter; unwrap one level so the provider can be recognised.
*/
export function unwrapSafeLink(uri: string): string {
try {
const u = new URL(uri);
if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) {
const inner = u.searchParams.get('url');
if (inner && isHttpUrl(inner)) return inner;
}
} catch {
// not a parseable URL - keep as is
}
return uri;
}
/** The conferencing service a URL joins, or null for any other link. */
export function meetingProviderOf(uri: string): string | null {
let u: URL;
try {
u = new URL(unwrapSafeLink(uri));
} catch {
return null;
}
const host = u.hostname.toLowerCase();
const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname)));
return provider ? provider.name : null;
}
function firstMeetingUrl(text: string | undefined | null): MeetingLink | null {
if (!text) return null;
for (const raw of text.match(URL_RE) ?? []) {
// Trailing punctuation belongs to the sentence, not the URL.
const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, ''));
const provider = meetingProviderOf(candidate);
if (provider) return { uri: candidate, provider, derived: true };
}
return null;
}
/**
* The link that joins the meeting: the event's own virtual location first,
* then a URL used as the location, then a known conferencing URL found in the
* location or the description.
*/
export function findMeetingLink(event: EventPlaces): MeetingLink | null {
for (const vl of Object.values(event.virtualLocations ?? {})) {
const uri = vl?.uri;
if (isHttpUrl(uri)) {
return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false };
}
}
const locations = Object.values(event.locations ?? {});
for (const loc of locations) {
const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description);
if (found) return found;
}
const inDescription = firstMeetingUrl(event.description);
if (inDescription) return inDescription;
// Last resort: a virtual location in another scheme the app may open (a
// tel: dial-in, say). App schemes like msteams:, zoommtg: or sip: are left
// out because openExternalUrl refuses them, and a Join button that does
// nothing is worse than none.
for (const vl of Object.values(event.virtualLocations ?? {})) {
const uri = vl?.uri?.trim();
if (isSafeExternalUrl(uri)) return { uri, derived: false };
}
return null;
}
/** The first location's display name, if any. */
export function primaryLocationName(event: Pick<EventPlaces, 'locations'>): string | undefined {
const name = Object.values(event.locations ?? {})[0]?.name?.trim();
return name || undefined;
}
// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"…
// A location like this names the service, not a place: tapping it should join
// the meeting, never search the maps for it. The whole label must be a service
// name, so "Salle Teams" or "Studio Zoom" stay places.
const MEETING_LABELS = new Set([
'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby',
'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne',
]);
export function isMeetingLabel(location: string): boolean {
const label = location
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^(reunion|meeting|online meeting|appel|call) /, '')
.replace(/ (meeting|reunion|call)$/, '');
return MEETING_LABELS.has(label);
}
export type LocationAction =
| { kind: 'url'; uri: string }
| { kind: 'maps'; query: string };
/**
* What tapping the location does: open it when it is a URL, join the meeting
* when it only names the service, search the maps otherwise.
*/
export function locationAction(location: string, meeting: MeetingLink | null): LocationAction {
const value = location.trim();
if (isHttpUrl(value)) return { kind: 'url', uri: value };
if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri };
return { kind: 'maps', query: value };
}
/**
* A maps URL for a free-text address. Android's `geo:` intent opens Google
* Maps (or the user's chosen maps app) straight on the search; elsewhere the
* Google Maps universal link opens the app when installed, the site otherwise.
*/
export function mapsUrl(query: string, platform: string): string {
const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim());
if (platform === 'android') return `geo:0,0?q=${q}`;
return `https://www.google.com/maps/search/?api=1&query=${q}`;
}
+19 -32
View File
@@ -2,20 +2,16 @@ import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Crypto from 'expo-crypto';
import type { ReleaseAsset } from '../api/updates';
import { Sha256 } from './sha256';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
// chunks are faster but balloon the JS string buffer (base64 reads on this
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
const HASH_CHUNK_BYTES = 256 * 1024;
export interface InstallProgress {
phase: 'downloading' | 'verifying' | 'installing';
/** 0-1 during download; undefined for installing phase */
/** 0-1 during download; undefined while verifying and installing */
progress?: number;
/** Bytes received during current download */
bytesWritten?: number;
@@ -31,34 +27,25 @@ export interface InstallParams {
expectedSha256?: string | null;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')).join('');
}
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
const hasher = new Sha256();
let offset = 0;
while (offset < totalBytes || totalBytes === 0) {
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
encoding: FileSystemLegacy.EncodingType.Base64,
position: offset,
length,
});
if (!chunkB64) break;
const bytes = base64ToBytes(chunkB64);
if (bytes.length === 0) break;
/**
* SHA-256 of a downloaded file. The bytes are read and hashed natively:
* hashing base64 chunks in JS took longer than the download itself and
* looked like a second, slower download. The JS hasher stays as a fallback
* for a build without expo-crypto's digest.
*/
export async function hashFile(uri: string): Promise<string> {
const bytes = await new File(uri).bytes();
try {
return toHex(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, bytes));
} catch {
const hasher = new Sha256();
hasher.update(bytes);
offset += bytes.length;
if (totalBytes > 0) {
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
}
if (bytes.length < length) break;
return hasher.digestHex();
}
return hasher.digestHex();
}
export async function downloadAndInstallApk(
@@ -117,8 +104,8 @@ export async function downloadAndInstallApk(
// signed with a different key — but that fails late (after the user sees a
// confusing system dialog). Bailing here surfaces the problem cleanly.
if (expectedSha256) {
onProgress?.({ phase: 'verifying', progress: 0 });
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
onProgress?.({ phase: 'verifying' });
const actual = await hashFile(result.uri);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await tryDelete(result.uri);
throw new Error(
+61 -6
View File
@@ -104,8 +104,8 @@ function randomState(): string {
}
// Anything from the redirect fragment is attacker-influenced: another app can
// register the same custom scheme. Only accept https endpoints, and only a
// token endpoint that lives on the mail server's own host (or the webmail's).
// register the same custom scheme. External token endpoints need verification
// through the selected webmail, rather than trusting the redirect alone.
function isHttpsUrl(value: string | null): value is string {
return !!value && /^https:\/\/[^/?#\s]+/i.test(value);
}
@@ -133,6 +133,60 @@ export function isAcceptableTokenEndpoint(
return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`));
}
// External IdPs must be authorized by the selected webmail over TLS, never
// by callback fields or a shared DNS suffix. Bind all returned identifiers.
async function isTrustedHandoffEndpoint(
tokenEndpoint: string,
serverUrl: string,
webmailUrl: string,
clientId: string,
): Promise<boolean> {
if (isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) return true;
function httpsUrl(value: unknown): value is string {
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
return url.protocol === 'https:' && !url.username && !url.password && !url.hash;
} catch {
return false;
}
}
if (!httpsUrl(webmailUrl) || !httpsUrl(tokenEndpoint) || !httpsUrl(serverUrl)) return false;
const base = webmailUrl.replace(/\/+$/, '');
try {
const load = async (path: string) => {
const url = `${base}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await secureFetch(url, {
headers: { Accept: 'application/json' },
redirect: 'error',
signal: controller.signal,
timeoutMs: 8000,
});
// The native client-certificate bridge omits the final URL. Without
// transport evidence of its destination, external-IdP trust must fail.
if (!response.ok || response.url !== url || response.redirected) {
throw new Error('Untrusted metadata response');
}
return await response.json();
} finally {
clearTimeout(timeout);
}
};
const config = await load('/api/config');
if (
config.oauthEnabled !== true || config.oauthClientId !== clientId ||
config.jmapServerUrl !== serverUrl || !httpsUrl(config.oauthIssuerUrl)
) return false;
const metadata = await load('/api/auth/oauth/metadata');
return metadata.issuer === config.oauthIssuerUrl && metadata.token_endpoint === tokenEndpoint;
} catch {
return false;
}
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
@@ -206,7 +260,7 @@ export async function runWebmailHandoff(
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) {
if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) {
throw new HandoffError('Sign-in response token endpoint is not trusted');
}
const refreshToken = params.get('refresh_token') ?? undefined;
@@ -455,9 +509,10 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
if (!accessToken || !tokenEndpoint || !clientId) {
throw new PairingError('bad_response', 'Pairing response missing token material', { host });
}
// The mail server's own host, or the webmail's token proxy
// (`<webmail>/api/auth/pair/token`); nothing else gets the refresh token.
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, base)) {
// The mail server's own host, the webmail's token proxy
// (`<webmail>/api/auth/pair/token`), or an external identity provider the
// webmail itself names; nothing else gets the refresh token.
if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, base, clientId))) {
throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host });
}
const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined;
+105
View File
@@ -12,6 +12,7 @@ import {
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
import { singleLine } from './single-line';
import {
lastNotifiedKey,
migrateLegacyPushKeys,
@@ -87,6 +88,7 @@ interface ShowNotificationOptions {
notificationId: string;
title: string;
body: string;
preview?: string;
initials: string;
bgColorHex: string;
iconUrl?: string;
@@ -101,6 +103,9 @@ interface ShowNotificationOptions {
// several deliveries under a "+N more" summary instead of stacking them.
groupKey: string;
groupTitle: string;
markReadLabel?: string;
deleteLabel?: string;
replyLabel?: string;
}
interface BulwarkFcmNative {
@@ -505,10 +510,16 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
const faviconDomain = favicons ? getFaviconDomain(address) : null;
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const preview = email.preview ? singleLine(email.preview) : undefined;
const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read');
const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete');
const replyLabel = translate(locale, 'email_viewer.reply', 'Reply');
await native.showNotification({
notificationId: `mail:${email.id}`,
title,
body,
preview,
initials,
bgColorHex,
iconUrl,
@@ -519,6 +530,9 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
jmapAccountId: emailAccountId,
groupKey,
groupTitle,
markReadLabel,
deleteLabel,
replyLabel,
});
}
@@ -539,3 +553,94 @@ function hslToHex(hsl: string): string {
};
return `#${component(0)}${component(8)}${component(4)}`;
}
export interface NotificationActionPayload {
action: 'markRead' | 'delete';
emailId: string;
accountId: string;
jmapAccountId?: string;
}
export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null {
if (!data || typeof data !== 'object') return null;
const d = data as Record<string, unknown>;
const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined;
const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined;
const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined;
const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined;
if ((action === 'markRead' || action === 'delete') && emailId && accountId) {
return { action, emailId, accountId, jmapAccountId };
}
return null;
}
export async function handleNotificationAction(data: unknown): Promise<void> {
const payload = parseNotificationActionPayload(data);
if (!payload) {
console.warn('[push] invalid notification action payload', data);
return;
}
const { action, emailId, accountId, jmapAccountId } = payload;
console.log(`[push] executing notification action "${action}" for email ${emailId}`);
try {
const session = await openDetachedSession(accountId);
if (!session) {
console.warn('[push] failed to open detached session for account', accountId);
return;
}
const emailAccountId = jmapAccountId ?? session.jmapAccountId;
if (action === 'markRead') {
const res = await jmapPost(session, [
[
'Email/set',
{
accountId: emailAccountId,
update: {
[emailId]: { 'keywords/$seen': true },
},
},
'0',
],
]);
assertEmailUpdated(res, emailId);
} else if (action === 'delete') {
const mbRes = await jmapPost(session, [
['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'],
]);
const [mbName, mbBody] = mbRes[0] ?? [];
const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : [];
const trash = mailboxes.find((m) => m.role === 'trash');
// Never destroy from a notification: without a Trash (a shared
// account, or a Mailbox/get that failed) the message stays put.
if (!trash) {
console.warn('[push] no trash mailbox for notification delete', emailAccountId);
return;
}
const res = await jmapPost(session, [
[
'Email/set',
{
accountId: emailAccountId,
update: {
[emailId]: { mailboxIds: { [trash.id]: true } },
},
},
'0',
],
]);
assertEmailUpdated(res, emailId);
}
} catch (err) {
console.warn('[push] notification action failed', action, emailId, err);
}
}
function assertEmailUpdated(responses: Array<[string, Record<string, any>, string]>, emailId: string): void {
const [name, body] = responses[0] ?? [];
if (name !== 'Email/set' || !body?.updated || !(emailId in body.updated)) {
throw new Error(`Email/set did not update ${emailId}: ${JSON.stringify(body?.notUpdated?.[emailId] ?? body ?? null)}`);
}
}
+2
View File
@@ -1443,6 +1443,8 @@ export interface NotificationTapPayload {
// The JMAP account the message lives in: a group or shared mailbox's account
// when it isn't the user's own. Absent on older notifications.
jmapAccountId?: string;
// Notification action tapped (e.g. 'reply' to jump straight to Compose)
action?: 'reply';
}
/**
+4 -5
View File
@@ -1,8 +1,7 @@
// Minimal SHA-256 implementation. Used to verify APK update downloads against
// the checksum published in the GitHub release body — the JS runtime has no
// built-in `crypto.subtle.digest` and we don't currently depend on
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
// MB on a modern phone, which is fine for the rare update flow.
// Minimal SHA-256 implementation. The fallback for verifying APK update
// downloads when expo-crypto's native digest is unavailable (see
// `hashFile` in install-update.ts); far too slow to be the main path.
// Public-domain algorithm (FIPS 180-4).
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
+203 -283
View File
@@ -8,25 +8,23 @@ import {
StyleSheet,
Pressable,
ActivityIndicator,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Calendar as CalendarIcon,
ChevronDown,
ChevronLeft,
ChevronRight,
Plus,
Calendar1,
CalendarDays,
LayoutGrid,
List as ListIcon,
Columns3,
Columns4,
Grid3x3,
ListChecks,
Menu,
Rows2,
Square,
} from 'lucide-react-native';
import {
format,
startOfWeek,
endOfWeek,
addDays,
subDays,
addMonths,
@@ -36,10 +34,9 @@ import {
type Locale,
} from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale';
import { displayNow, isDisplayToday } from '../lib/calendar-timezone';
import { displayNow } from '../lib/calendar-timezone';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { useSettingsStore } from '../stores/settings-store';
import { MonthView } from '../components/calendar/MonthView';
@@ -47,14 +44,15 @@ import { MonthScrollView } from '../components/calendar/MonthScrollView';
import { WeekView } from '../components/calendar/WeekView';
import { TimeGridScrollView } from '../components/calendar/TimeGridScrollView';
import { AgendaView } from '../components/calendar/AgendaView';
import { EventCard } from '../components/calendar/EventCard';
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
import { EventModal } from '../components/calendar/EventModal';
import {
RecurrenceScopeDialog,
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
import { CalendarSidebarDrawer, type DrawerViewOption } from '../components/calendar/CalendarSidebarDrawer';
import { CalendarFab } from '../components/calendar/CalendarFab';
import { MonthDropdown } from '../components/calendar/MonthDropdown';
import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
@@ -71,6 +69,7 @@ import {
scrollWindowLoadRange,
windowStateForJump,
type CalendarFocus,
type ScrollViewMode,
type ScrollWindowOptions,
type ScrollWindowState,
} from '../lib/calendar-scroll-window';
@@ -80,14 +79,11 @@ import {
applySharedCalendarColors,
buildEventDayIndex,
dayKey,
eventsOnDayFromIndex,
getEventStartDate,
getPrimaryCalendarId,
getTaskDueDate,
pickUnusedCalendarColor,
sharedCalendarColorKey,
type EventDayIndex,
type TimeFormat,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import {
@@ -107,7 +103,18 @@ import { shareEventICS } from '../lib/calendar-ics-export';
import * as Clipboard from 'expo-clipboard';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'day' | 'agenda';
type ViewMode = 'month' | 'week' | '3day' | 'day' | 'agenda';
// The three-day grid scrolls through the same window of days as the day view.
function windowMode(mode: ViewMode): ScrollViewMode {
return mode === '3day' ? 'day' : mode;
}
// A window without free scrolling: one period, three days for the three-day grid.
function fixedWindowFor(mode: ViewMode, anchor: Date): ScrollWindowState {
const state = fixedScrollWindowState(windowMode(mode), anchor);
return mode === '3day' ? { ...state, after: 2 } : state;
}
type PendingAction =
| {
kind: 'edit';
@@ -129,28 +136,10 @@ type PendingAction =
// the next arrow press or edge usually finds them there already.
const RANGE_MARGIN_DAYS = 14;
type WeekStart = 0 | 1 | 6;
// `firstDay`: the scrolled week grid reports the first column in view; the
// title then spans the seven days from there instead of the focused week.
function headerTitle(
viewMode: ViewMode,
currentDate: Date,
weekStartsOn: WeekStart,
locale: Locale,
firstDay?: Date | null,
): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale });
if (viewMode === 'day') return format(currentDate, 'EEE, MMM d, yyyy', { locale });
if (viewMode === 'week') {
const start = firstDay ?? startOfWeek(currentDate, { weekStartsOn });
const end = firstDay ? addDays(firstDay, 6) : endOfWeek(currentDate, { weekStartsOn });
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
return `${format(start, 'MMM d', { locale })} – ${format(end, 'd, yyyy', { locale })}`;
}
return `${format(start, 'MMM d', { locale })} – ${format(end, 'MMM d, yyyy', { locale })}`;
}
return format(currentDate, 'MMMM yyyy', { locale });
// The month on screen; the year only when it isn't this year.
function headerTitle(date: Date, locale: Locale): string {
const sameYear = date.getFullYear() === displayNow().getFullYear();
return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale });
}
export default function CalendarScreen() {
@@ -201,13 +190,14 @@ export default function CalendarScreen() {
// The sideways-scrolling week and day grids assume a left-to-right strip;
// right-to-left layouts keep them paged.
const freeScroll =
calendarFreeScroll && ((viewMode !== 'week' && viewMode !== 'day') || !I18nManager.isRTL);
calendarFreeScroll
&& ((viewMode !== 'week' && viewMode !== '3day' && viewMode !== 'day') || !I18nManager.isRTL);
const focusKey = dayKey(focus.date);
const activeWindowState = React.useMemo(
() =>
freeScroll
? normalizeScrollWindowState(windowState, viewMode, parseDayKey(focusKey))
: fixedScrollWindowState(viewMode, parseDayKey(focusKey)),
? normalizeScrollWindowState(windowState, windowMode(viewMode), parseDayKey(focusKey))
: fixedWindowFor(viewMode, parseDayKey(focusKey)),
[freeScroll, windowState, viewMode, focusKey],
);
React.useEffect(() => {
@@ -218,7 +208,7 @@ export default function CalendarScreen() {
[activeWindowState, windowOptions],
);
// Changes whenever a fresh window starts; the views remount on it.
const windowKey = `${activeWindowState.mode}:${activeWindowState.anchorKey}`;
const windowKey = `${viewMode}:${activeWindowState.anchorKey}`;
const loadRange = React.useMemo(() => {
const { after, before } = scrollWindowLoadRange(scrollWindow, RANGE_MARGIN_DAYS);
return { after: after.toISOString(), before: before.toISOString() };
@@ -233,7 +223,6 @@ export default function CalendarScreen() {
const [tasksVisible, setTasksVisible] = React.useState(false);
const [importVisible, setImportVisible] = React.useState(false);
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
@@ -467,7 +456,7 @@ export default function CalendarScreen() {
setSelectedDate(date);
setVisibleDate(null);
setFocus((prev) => ({ date, nonce: prev.nonce + 1 }));
setWindowState((prev) => windowStateForJump(prev, viewMode, date, windowOptions));
setWindowState((prev) => windowStateForJump(prev, windowMode(viewMode), date, windowOptions));
},
[viewMode, windowOptions],
);
@@ -479,7 +468,7 @@ export default function CalendarScreen() {
(side: 'before' | 'after') => {
setLoadingEdge(side === 'before' ? 'start' : 'end');
setWindowState((prev) =>
growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side),
growScrollWindow(normalizeScrollWindowState(prev, windowMode(viewMode), focus.date), side),
);
},
[viewMode, focus.date],
@@ -493,6 +482,7 @@ export default function CalendarScreen() {
const base = visibleDate ?? focus.date;
jumpTo(
viewMode === 'week' ? subWeeks(base, 1)
: viewMode === '3day' ? subDays(base, 3)
: viewMode === 'day' ? subDays(base, 1)
: subMonths(base, 1),
);
@@ -502,6 +492,7 @@ export default function CalendarScreen() {
const base = visibleDate ?? focus.date;
jumpTo(
viewMode === 'week' ? addWeeks(base, 1)
: viewMode === '3day' ? addDays(base, 3)
: viewMode === 'day' ? addDays(base, 1)
: addMonths(base, 1),
);
@@ -519,16 +510,63 @@ export default function CalendarScreen() {
setViewMode(mode);
setVisibleDate(null);
setFocus((prev) => ({ date, nonce: prev.nonce + 1 }));
setWindowState(freshScrollWindowState(mode, date));
setWindowState(freshScrollWindowState(windowMode(mode), date));
},
[viewMode, visibleDate, focus.date],
);
// The views, listed at the top of the drawer.
const viewOptions = React.useMemo<DrawerViewOption[]>(
() => [
{ key: 'agenda', label: t('calendar.views.schedule', 'Schedule'), Icon: Rows2 },
{ key: 'day', label: t('calendar.views.day', 'Day'), Icon: Square },
{ key: '3day', label: t('calendar.views.three_days', '3 days'), Icon: Columns3 },
{ key: 'week', label: t('calendar.views.week', 'Week'), Icon: Columns4 },
{ key: 'month', label: t('calendar.views.month', 'Month'), Icon: Grid3x3 },
],
[t],
);
// The month that drops down under the title; the title follows the month
// swiped to while it is open.
const [monthPanelOpen, setMonthPanelOpen] = React.useState(false);
const [panelMonth, setPanelMonth] = React.useState<Date | null>(null);
const toggleMonthPanel = React.useCallback(() => {
setPanelMonth(null);
setMonthPanelOpen((open) => !open);
}, []);
const handlePanelSelect = React.useCallback(
(date: Date) => {
setMonthPanelOpen(false);
setPanelMonth(null);
jumpTo(date);
},
[jumpTo],
);
// New tasks open the tasks sheet on an empty editor.
const [newTask, setNewTask] = React.useState(false);
const openNewTask = React.useCallback(() => {
setTasksInitialId(null);
setNewTask(true);
setTasksVisible(true);
}, []);
// Picking a day only moves the selection; the view stays where it is.
const handleSelectDate = React.useCallback((date: Date) => {
setSelectedDate(date);
}, []);
// A day in the month grid or a day header in the week grids opens that
// day, as in Google Calendar.
const openDay = React.useCallback((date: Date) => {
setSelectedDate(date);
setViewMode('day');
setVisibleDate(null);
setFocus((prev) => ({ date, nonce: prev.nonce + 1 }));
setWindowState(freshScrollWindowState('day', date));
}, []);
const openCreate = React.useCallback((date?: Date) => {
setModalEvent(null);
setModalDate(date);
@@ -771,14 +809,7 @@ export default function CalendarScreen() {
[deleteEvent, reportError],
);
const onRefresh = React.useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const onRefresh = React.useCallback(() => refresh(), [refresh]);
// The store flips the checkbox optimistically and reverts it when the
// server refuses; say so instead of leaving the user guessing.
@@ -922,7 +953,7 @@ export default function CalendarScreen() {
[t, removeCalendar, reportError],
);
const isSelectedToday = isDisplayToday(selectedDate);
const titleDate = (monthPanelOpen && panelMonth) || visibleDate || focus.date;
return (
<SafeAreaView style={styles.container} edges={['top']}>
@@ -936,96 +967,83 @@ export default function CalendarScreen() {
>
<Menu size={20} color={c.text} />
</Pressable>
<View style={styles.headerLeft}>
{/* Four view buttons leave less room: shrink a long title instead of wrapping it. */}
<Text style={styles.headerTitle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.75}>
{headerTitle(
viewMode,
visibleDate ?? focus.date,
calendarFirstDayOfWeek,
locale,
viewMode === 'week' && freeScroll ? visibleDate : null,
)}
<Pressable
onPress={toggleMonthPanel}
style={styles.titleBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('calendar.mini_calendar_change', 'Change month')}
accessibilityState={{ expanded: monthPanelOpen }}
>
<Text style={styles.headerTitle} numberOfLines={1}>
{headerTitle(titleDate, locale)}
</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday
? t('calendar.views.today', 'Today')
: format(selectedDate, 'EEE, MMM d', { locale })}
</Text>
</View>
<View style={monthPanelOpen && styles.chevronOpen}>
<ChevronDown size={18} color={c.textSecondary} />
</View>
</Pressable>
<View style={styles.headerActions}>
{/* Without free scrolling the views don't swipe: keep arrows. */}
{!freeScroll && (
<>
<Pressable
onPress={goPrev}
style={styles.headerBtn}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_prev', 'Previous')}
>
<ChevronLeft size={22} color={c.text} />
</Pressable>
<Pressable
onPress={goNext}
style={styles.headerBtn}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_next', 'Next')}
>
<ChevronRight size={22} color={c.text} />
</Pressable>
</>
)}
<Pressable
style={styles.headerBtn}
onPress={() => {
setMonthPanelOpen(false);
goToday();
}}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')}
>
<CalendarIcon size={26} color={c.text} strokeWidth={1.75} />
<Text style={styles.todayIconText}>{format(displayNow(), 'd')}</Text>
</Pressable>
{enableCalendarTasks && (
<Pressable
style={styles.headerBtn}
onPress={() => setTasksVisible(true)}
hitSlop={6}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.views.tasks', 'Tasks')}
>
<ListChecks size={20} color={c.text} />
<ListChecks size={22} color={c.text} />
</Pressable>
)}
<View style={styles.viewToggle}>
{(['month', 'week', 'day', 'agenda'] as ViewMode[]).map((mode) => {
const Icon =
mode === 'month' ? LayoutGrid
: mode === 'week' ? CalendarDays
: mode === 'day' ? Calendar1
: ListIcon;
const active = viewMode === mode;
const label =
mode === 'month' ? t('calendar.views.month', 'Month')
: mode === 'week' ? t('calendar.views.week', 'Week')
: mode === 'day' ? t('calendar.views.day', 'Day')
: t('calendar.views.agenda', 'Agenda');
return (
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => changeViewMode(mode)}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.primary : c.textMuted} />
</Pressable>
);
})}
</View>
<Pressable
style={styles.fab}
onPress={() => openCreate(selectedDate)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
</View>
<View style={styles.nav}>
<Pressable
onPress={goPrev}
style={styles.navBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_prev', 'Previous')}
>
<ChevronLeft size={20} color={c.text} />
</Pressable>
<Button variant="outline" size="sm" onPress={goToday}>
{t('calendar.views.today', 'Today')}
</Button>
<Pressable
onPress={goNext}
style={styles.navBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_next', 'Next')}
>
<ChevronRight size={20} color={c.text} />
</Pressable>
</View>
{monthPanelOpen && (
<MonthDropdown
month={titleDate}
selectedDate={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
onMonthChange={setPanelMonth}
onSelectDate={handlePanelSelect}
/>
)}
{error && (
<View style={styles.errorBanner}>
@@ -1051,7 +1069,7 @@ export default function CalendarScreen() {
showTimeInMonthView={calendarShowTimeInMonth}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={openDay}
onLongPressDate={openCreate}
/>
)}
@@ -1065,16 +1083,17 @@ export default function CalendarScreen() {
weekStartsOn={calendarFirstDayOfWeek}
showWeekNumbers={calendarShowWeekNumbers}
showTimeInMonthView={calendarShowTimeInMonth}
fill
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={openDay}
onLongPressDate={openCreate}
/>
)}
{(viewMode === 'day' || (viewMode === 'week' && freeScroll)) && (
{(viewMode === 'day' || viewMode === '3day' || (viewMode === 'week' && freeScroll)) && (
<TimeGridScrollView
key={windowKey}
mode={viewMode === 'day' ? 'day' : 'week'}
mode={viewMode}
focus={focus}
window={scrollWindow}
onExtendStart={freeScroll && scrollWindow.canExtendStart ? extendWindowStart : undefined}
@@ -1087,7 +1106,7 @@ export default function CalendarScreen() {
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={viewMode === 'day' ? handleSelectDate : openDay}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
/>
@@ -1102,7 +1121,7 @@ export default function CalendarScreen() {
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={openDay}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
/>
@@ -1122,35 +1141,25 @@ export default function CalendarScreen() {
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
weekStartsOn={calendarFirstDayOfWeek}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onCreateAt={openCreate}
/>
)}
{viewMode === 'month' && (
<View style={styles.dayDetail}>
<View style={styles.dayDetailHeader}>
<Text style={styles.dayDetailTitle}>
{isSelectedToday
? t('calendar.events.today_header', 'Today')
: format(selectedDate, 'EEEE, MMMM d', { locale })}
</Text>
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
</View>
<DayEventList
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
refreshing={refreshing}
onRefresh={onRefresh}
/>
{loading && (
<View style={styles.loadingBadge} pointerEvents="none">
<ActivityIndicator size="small" color={c.textMuted} />
</View>
)}
</View>
<CalendarFab
onNewEvent={() => openCreate(selectedDate)}
onNewTask={enableCalendarTasks ? openNewTask : undefined}
/>
<EventDetailSheet
event={detailEvent}
calendars={calendars}
@@ -1195,6 +1204,17 @@ export default function CalendarScreen() {
<CalendarSidebarDrawer
visible={sidebarVisible}
views={viewOptions}
activeView={viewMode}
onSelectView={(key) => {
setSidebarVisible(false);
setMonthPanelOpen(false);
changeViewMode(key as ViewMode);
}}
onRefresh={() => {
setSidebarVisible(false);
void onRefresh();
}}
calendars={eventCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
@@ -1222,7 +1242,8 @@ export default function CalendarScreen() {
calendars={taskSheetCalendars}
timeFormat={calendarTimeFormat}
initialTaskId={tasksInitialId}
onClose={() => { setTasksVisible(false); setTasksInitialId(null); }}
startNew={newTask}
onClose={() => { setTasksVisible(false); setTasksInitialId(null); setNewTask(false); }}
onCreate={createTask}
onUpdate={updateTask}
onToggle={handleToggleTask}
@@ -1262,67 +1283,6 @@ export default function CalendarScreen() {
);
}
function DayEventList({
date,
eventsByDay,
calendars,
timeFormat,
currentUserEmails,
onSelectEvent,
refreshing,
onRefresh,
}: {
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
timeFormat?: TimeFormat;
currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dayEvents = React.useMemo(
() => eventsOnDayFromIndex(eventsByDay, date),
[eventsByDay, date],
);
if (dayEvents.length === 0) {
return (
<ScrollView
contentContainerStyle={styles.emptyState}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
}
>
<CalendarDays size={32} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>{t('calendar.events.no_events', 'No events')}</Text>
<Text style={styles.emptySubtitle}>{t('calendar.events.tap_to_create', 'Tap + to create one')}</Text>
</ScrollView>
);
}
return (
<ScrollView
contentContainerStyle={styles.dayList}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
}
>
{dayEvents.map((event) => (
<EventCard
key={event.id}
event={event}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
))}
</ScrollView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
@@ -1330,57 +1290,38 @@ function makeStyles(c: ThemePalette) {
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
headerBtn: {
width: 36,
height: 36,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
},
headerLeft: { flex: 1 },
headerTitle: { ...typography.h3, color: c.text },
headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
viewToggle: {
flexDirection: 'row',
backgroundColor: c.surface,
borderRadius: radius.md,
padding: 2,
},
viewToggleBtn: {
width: 30,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
viewToggleBtnActive: { backgroundColor: c.background },
fab: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
nav: {
titleBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xs,
gap: spacing.lg,
gap: 2,
minHeight: 40,
},
navBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
chevronOpen: { transform: [{ rotate: '180deg' }] },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: 2 },
// Today's date inside the calendar icon, below its rings.
todayIconText: {
position: 'absolute',
top: 15,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 10,
lineHeight: 12,
fontWeight: '700',
color: c.text,
},
errorBanner: {
@@ -1392,28 +1333,7 @@ function makeStyles(c: ThemePalette) {
content: { flex: 1 },
dayDetail: {
flex: 1,
borderTopWidth: 1,
borderTopColor: c.border,
},
dayDetailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
dayDetailTitle: { ...typography.bodyMedium, color: c.text },
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xl,
gap: spacing.xs,
flexGrow: 1,
},
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
// Loading shows as a small spinner over the view's top corner.
loadingBadge: { position: 'absolute', top: spacing.xs, right: spacing.md },
});
}
+102 -30
View File
@@ -2,6 +2,7 @@ import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
Keyboard, Dimensions, Platform,
} from 'react-native';
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -639,6 +640,42 @@ function EmailViewer({ route, navigation }: Props) {
};
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Track the visible keyboard obstruction so quick reply stays visible and
// the bottom bar hides while typing. On Android edge-to-edge, the IME inset
// is measured from the top of the gesture bar rather than screen bottom,
// so we derive obstruction height from Dimensions.screen.
const [kbObstruction, setKbObstruction] = React.useState(0);
React.useEffect(() => {
const recompute = (endY?: number, height?: number) => {
const screenH = Dimensions.get('screen').height;
if (typeof endY === 'number' && screenH - endY > 0) {
setKbObstruction(screenH - endY);
} else if (typeof height === 'number' && height > 0) {
setKbObstruction(height);
} else {
setKbObstruction(0);
}
};
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
recompute(e.endCoordinates?.screenY, e.endCoordinates?.height);
}),
Keyboard.addListener('keyboardWillHide', () => setKbObstruction(0)),
]
: [
Keyboard.addListener('keyboardDidShow', (e) => {
recompute(e.endCoordinates?.screenY, e.endCoordinates?.height);
}),
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
];
return () => {
for (const s of subs) s.remove();
};
}, []);
// Drop optional toolbar buttons on narrow screens.
const showMarkUnread = windowWidth >= 340;
const showArchive = windowWidth >= 400 && canArchive;
@@ -738,7 +775,7 @@ function EmailViewer({ route, navigation }: Props) {
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
scrollEnabled={!pagerLocked}
scrollEnabled={!pagerLocked && kbObstruction === 0}
onScrollBeginDrag={releaseNeighbours}
onMomentumScrollEnd={onMomentumEnd}
renderItem={({ item, index }) => (
@@ -769,6 +806,7 @@ function EmailViewer({ route, navigation }: Props) {
scheduleMarkRead={scheduleMarkRead}
styles={styles}
bottomBarHeight={bottomBarHeight}
kbObstruction={item.id === activeEmailId ? kbObstruction : 0}
onToggleStar={toggleStarFor}
onAddressPress={setAddressSheet}
onEmailPatched={onEmailPatched}
@@ -780,33 +818,35 @@ function EmailViewer({ route, navigation }: Props) {
)}
/>
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
{kbObstruction === 0 && (
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
)}
</>
)}
@@ -917,6 +957,7 @@ interface EmailPaneProps {
scheduleMarkRead: (email: Email) => () => void;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
kbObstruction: number;
onToggleStar: (email: Email) => void;
onAddressPress: (address: EmailAddress) => void;
onEmailPatched: (email: Email) => void;
@@ -933,7 +974,7 @@ interface EmailPaneProps {
function EmailPane({
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
ensureThread, scheduleMarkRead, styles, bottomBarHeight, onToggleStar, onAddressPress,
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
onEmailPatched, onReply, onSwipe, onZoomChange,
}: EmailPaneProps) {
const c = useColors();
@@ -1028,13 +1069,42 @@ function EmailPane({
onZoomChange(z);
};
const scrollRef = React.useRef<ScrollView>(null);
const isInputFocused = React.useRef(false);
const bottomPadding = kbObstruction > 0
? kbObstruction + spacing.md
: bottomBarHeight + spacing.lg;
const handleQuickReplyFocus = () => {
isInputFocused.current = true;
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true });
}, 100);
};
const handleQuickReplyBlur = () => {
isInputFocused.current = false;
};
React.useEffect(() => {
if (kbObstruction > 0 && isInputFocused.current) {
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true });
}, 50);
}
}, [kbObstruction]);
return (
<ScrollView
ref={scrollRef}
style={styles.scroll}
// At least a page tall: a single message's body takes the space below
// its header, and the quick reply stays at the bottom however the body
// grows or shrinks once it has measured itself.
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomBarHeight + spacing.lg }]}
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomPadding }]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled={!pinching}
>
{/* Subject block */}
@@ -1112,6 +1182,8 @@ function EmailPane({
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
onFocus={handleQuickReplyFocus}
onBlur={handleQuickReplyBlur}
/>
)}
</View>