mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -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 }}"
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+124
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.75",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
+61
-6
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+203
-283
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -981,6 +1022,19 @@ function EmailPane({
|
||||
|
||||
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
|
||||
|
||||
// Keeps the quick reply in view while the keyboard opens. Above the
|
||||
// skeleton's early return: a page opened by id alone (a notification)
|
||||
// renders the skeleton first, and a hook after it crashed the app.
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const isInputFocused = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (kbObstruction > 0 && isInputFocused.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: true });
|
||||
}, 50);
|
||||
}
|
||||
}, [kbObstruction]);
|
||||
|
||||
const toggleCard = (mid: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev ?? []);
|
||||
@@ -1028,13 +1082,31 @@ function EmailPane({
|
||||
onZoomChange(z);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 +1184,8 @@ function EmailPane({
|
||||
jmapAccountId={jmapAccountId}
|
||||
onMoreOptions={() => onReply('reply', newest)}
|
||||
onSent={onEmailPatched}
|
||||
onFocus={handleQuickReplyFocus}
|
||||
onBlur={handleQuickReplyBlur}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user