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 | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,4 +51,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -74,7 +74,9 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<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
|
||||
|
||||
@@ -14,6 +14,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">22dp</dimen>
|
||||
<dimen name="widget_radius">16dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# Android widgets: verification checklist
|
||||
|
||||
Every tap target of the 32 Android home-screen widgets (`src/widgets/`), how
|
||||
it was tested on the emulator, and what happened. Started for the report
|
||||
"widget actions sometimes don't work" (#52 follow-up).
|
||||
|
||||
## Rig
|
||||
|
||||
- Emulator `Bulwark_Pixel7_API35` (Pixel 7, Android 15, x86_64), Pixel
|
||||
launcher, release APK built with `-PreactNativeArchitectures=x86_64`.
|
||||
- Servers: the standing test servers, Stalwart 0.16 (`stalwart-test`) and
|
||||
1.0 (`stalwart-test-v1`), reached through a logging reverse proxy that
|
||||
records every JMAP method, `notUpdated`/`notCreated` and errors, and can
|
||||
delay or fail chosen methods on request.
|
||||
- Accounts: `usera@example.org` (password) and `userb@example.org` (OAuth
|
||||
through Stalwart's own authorization server) signed in at once. Both are
|
||||
members of the `team@` group; `usera`'s calendar is shared with `userb`.
|
||||
Extra test data: tasks for both, invitations each way through server-side
|
||||
scheduling.
|
||||
- Placed: Triage, Tasks, Invitations (others in phase 3).
|
||||
|
||||
## States
|
||||
|
||||
| Code | State | How |
|
||||
|---|---|---|
|
||||
| FG | App on screen when the link arrives | `am start` of the link while the app is in front |
|
||||
| BG | App backgrounded, JS runtime alive | Home after using the app |
|
||||
| COLD | Process dead | `adb root`, `kill -9 <pid>` (not `am kill`, not `force-stop`) |
|
||||
| OFF | Offline | `cmd connectivity airplane-mode enable` |
|
||||
| ACC2 | Item belongs to the non-active account | the other account active in the app |
|
||||
| SHARED | Item in a shared or group account | `usera`'s calendar seen by `userb` |
|
||||
| DBL | Two taps in quick succession | `input tap` twice, 150 ms and 1–1.5 s apart |
|
||||
| UPD | Tap while a refresh is running | proxy delays `Email/set` by 4 s, second tap meanwhile |
|
||||
|
||||
A widget tap cannot happen with the app in front, so FG applies to deep
|
||||
links only. Links were opened with `am start -a VIEW -f 0x10000000`, which is
|
||||
exactly what the widget provider does (`RNWidgetProvider.openUri`); the
|
||||
widget's own Reply, card and event taps were also tapped on the launcher.
|
||||
|
||||
## Phase 1 results (before fixes, Stalwart 0.16)
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | Widgets | BG | COLD | OFF | ACC2 | DBL | UPD | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | see A1, A2, A3 |
|
||||
| trash | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | same code path as archive |
|
||||
| triageNext | Triage | pass | pass | pass | n/a | pass | pass | local only |
|
||||
| rsvp | Invitations | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5 |
|
||||
| toggleTask | Tasks, Today overview | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5, A6 |
|
||||
| markRead | (no button uses it) | – | – | – | – | – | – | kept for the API |
|
||||
| any button | all | pass* | pass* | – | – | – | – | *on this emulator; A0 |
|
||||
|
||||
### Deep links (`links.*` in `src/widgets/clicks.ts`)
|
||||
|
||||
| Link | Used by | FG | BG | COLD | OFF | ACC2 | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| inbox | Inbox, Unread count, Folder counts, Tag, Attachments, Mail and next, Today overview | pass | pass | pass | pass | n/a | |
|
||||
| message | Inbox/Starred/All accounts rows, Latest message, Triage card, Mail and next, Attachments | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| reply | Triage | **fail** | **fail** | **fail** | **fail** | **fail** | L1 |
|
||||
| draft | Last draft | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| unified (all / starred / role) | All accounts, Starred, Folder counts, Last draft | pass | pass | pass | not run | n/a | |
|
||||
| scheduled | Outbox, Folder counts | pass | pass | pass | not run | n/a | |
|
||||
| search (empty / query) | Inbox, Search mail, Favourite people | pass | pass | pass | not run | n/a | empty search opens the list without focusing the field |
|
||||
| compose (plain) | Inbox, Last draft | pass | pass | pass | pass | n/a | |
|
||||
| compose with to + subject | Birthdays "Send wishes", Up next "Running late" | **fail** | **fail** | **fail** | **fail** | n/a | L2 |
|
||||
| calendar | all calendar and plans widgets | pass | pass | pass | **crash** | n/a | L3 |
|
||||
| event | Week, Month, Today, Next event, Up next, Countdown, Invitations, Mail and next | **fail** | pass | pass | **crash** | **fail** | L3, L5 |
|
||||
| contacts | Shortcuts, Favourite people, Birthdays | pass | pass | pass | not run | n/a | |
|
||||
| files | Shortcuts, Recent files | pass | pass | pass | not run | n/a | |
|
||||
| settings/vacation, /account, /calendar | Vacation, Storage, Free time, Tasks (off) | pass | pass | pass | not run | n/a | |
|
||||
| video URL | Up next "Join" | – | – | – | – | – | external https link, opens the browser |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result | Notes |
|
||||
|---|---|---|
|
||||
| App goes to the background | **fail** (sometimes) | R1 |
|
||||
| Account switched in the app | **fail** | R1: widgets kept the previous account until the next app start |
|
||||
| Cold periodic update | pass for mail | R2: calendar, tasks, files, scheduled stay as last loaded by the app |
|
||||
| Back online after an offline tap | **fail** | A1: nothing brings the change back until the app or a 30-minute update runs |
|
||||
|
||||
## Causes found
|
||||
|
||||
- **A0. Buttons silently dead on fast phones.** The widget library registers
|
||||
every button's `PendingIntent` with `requestCode = (int)
|
||||
System.currentTimeMillis()` and `FLAG_CANCEL_CURRENT`. All buttons of one
|
||||
widget share the same action and component, so two buttons registered in
|
||||
the same millisecond get the same identity and the second registration
|
||||
cancels the first: that button then does nothing at all. On the emulator
|
||||
each registration takes 3–10 ms (measured from the request codes in
|
||||
`dumpsys activity intents`), so every button survived; a current phone
|
||||
makes the binder call in well under a millisecond, which makes the dead
|
||||
button depend on timing — "sometimes". The cancelled-then-recreated
|
||||
records also pile up (227 for three widgets after an hour of testing).
|
||||
- **A1. Offline or failed calls look like they worked.** The action edits
|
||||
the stored snapshot first and relies on the next refresh to undo a failed
|
||||
call. Offline that refresh fails too and returns the edited snapshot, so
|
||||
an archive that never reached the server shows as done; a cold refresh
|
||||
never reloads calendar or tasks, so a refused RSVP or task change is never
|
||||
undone either. Failures are only `console.warn`ed.
|
||||
- **A2. A fast second tap repeats the first.** Two taps 150 ms apart both
|
||||
archived `d2aaaaa3`: the second tap hits the buttons of the old drawing,
|
||||
which still name the first message. Harmless for archive; for task ticks
|
||||
the code would flip the task back, because `toggleTask` inverts whatever
|
||||
the stored snapshot says instead of carrying the state the user saw.
|
||||
- **A3. A refresh brings back a message just archived.** Each tap is its own
|
||||
headless task in the same runtime; they interleave at every await. With
|
||||
`Email/set` delayed 4 s, tap 1's refresh read the server before tap 2's
|
||||
archive landed, was stored anyway (it started after tap 2's local edit, so
|
||||
`noteLocalChange` did not catch it) and redrew the archived message
|
||||
(`baaaaaai`, log 08:44:19), until tap 2's own refresh removed it again.
|
||||
Separately, load–edit–save of the snapshot is not serialized, so two
|
||||
concurrent edits can drop one, and redraws of different snapshots can land
|
||||
out of order. In one run the second tap archived a message the card was
|
||||
not showing.
|
||||
- **A4. RSVP and task ticks go to the wrong account after a switch.** They
|
||||
pick the client from `snapshot.activeAccountId` and look the item up by id
|
||||
in the current snapshot, not from the tap's own data. JMAP ids are small
|
||||
per-account sequences (`e`, `f`, `h1kbleaaaaaj`), so a tap on a drawing
|
||||
made before an account switch can match another account's task or event
|
||||
with the same id. Not reproduced on the device (the window between the
|
||||
stored refresh and the redraw is short); follows from the code.
|
||||
- **A5. Nothing tells the user.** See A1.
|
||||
- **A6. The task tick target is 22 dp.** A tap on the task's title or a few
|
||||
dp beside the circle does nothing.
|
||||
- **Lead 2 (`target` lacks `widgetId`) is ruled out**: the logged click data
|
||||
of Archive/Delete is `{"id":…,"widgetId":3,"accountId":…}`.
|
||||
- **Lead 4 (credentials in a cold runtime)**: the password account restores
|
||||
and acts from a cold start (archive, task tick). The OAuth account with an
|
||||
access token that expired while the app was dead is checked in phase 2.
|
||||
- **Lead 5 (`progressUpdated`)** is ruled out: the widget's task update has
|
||||
carried no `progressUpdated` since the B12 fix, and both 0.16 and 1.0
|
||||
accept it (`updated`, no `notUpdated`).
|
||||
- **R1. The refresh on going to the background often never runs.** With an
|
||||
account switched in the app and Home pressed, no refresh request reached
|
||||
the proxy and the widgets kept the old account's mail.
|
||||
- **R2. Cold refreshes skip calendar and tasks.** Only mail has JMAP code of
|
||||
its own; the rest needs the app's stores, which only run with the UI.
|
||||
- **L1. Widget Reply never opens the composer.** The reader opens on the
|
||||
message but the composer does not follow, in every state.
|
||||
- **L2. "Send wishes" loses its subject.** `parseDeepLink` takes the
|
||||
subject from the mailto parser, which returns an empty string rather than
|
||||
nothing, so the link's own `subject` is never used.
|
||||
- **L3. Calendar links crash the app offline.** On an offline cold start the
|
||||
Calendar tab mounts `CalendarShareSheet`, whose list of people to share
|
||||
with calls `ownPrincipalId()` while rendering; with no session that throws
|
||||
"Not authenticated - call connect() first" and the app closes.
|
||||
- **L4. Message links for the other account fail offline.** The link
|
||||
switches accounts first, which needs the server; offline the switch fails
|
||||
and the app shows "Couldn't open this".
|
||||
- **L5. Event links carry no account.** With the other account active, an
|
||||
event link opens the Calendar tab and nothing else (or, with an id that
|
||||
exists in both accounts, the wrong event). In the foreground the event
|
||||
link landed on the Inbox.
|
||||
|
||||
## Corrections to phase 1
|
||||
|
||||
- **L1 and L2 were faults of the test rig, not of the app.** `adb shell am
|
||||
start -d <url>` handed the URL to the device shell unquoted, and the shell
|
||||
cut it at the first `&`: Reply lost `&action=reply` (and `&thread=`), and
|
||||
"Send wishes" lost `&subject=`. Quoted, Reply opens the composer over the
|
||||
message and the subject arrives, cold, backgrounded and in the foreground,
|
||||
on 0.16 and 1.0.
|
||||
- **L4 is two app limits, not a widget fault.** A message link offline needs
|
||||
the message on the device: with the offline mail cache off (the default)
|
||||
a message is not kept across app starts and the reader says "Failed to
|
||||
load email". A link for the other account needs the server for the account
|
||||
switch, so offline it says "Couldn't open this".
|
||||
- **R1 had a second half.** Starting the background refresh directly (no
|
||||
timer) was not enough: fetch resolves every response through a zero-delay
|
||||
timer, and with the activity paused the first request went out and its
|
||||
answer was never read. The refresh now runs in a headless task of its own.
|
||||
|
||||
## After the fixes (phase 2)
|
||||
|
||||
Same rig. 1.0 was reached as `http://127.0.0.1:18191` through `adb
|
||||
reverse`, so its accounts (`usera@127.0.0.1`) sit next to the 0.16 ones;
|
||||
that path does not go through the emulator's network, so "offline" on 1.0
|
||||
was simulated with the proxy refusing `Email/set` (503) instead of airplane
|
||||
mode.
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | BG | COLD | OFF | ACC2 | DBL | UPD | 1.0 | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | pass | pass | pass | n/a | pass | pass | pass | offline: the message comes back on the card with "Couldn't archive. Tap to try again."; the tap archives it once online |
|
||||
| trash | pass | pass | pass | n/a | pass | pass | pass | offline: "Couldn't delete. Tap to try again."; the tap moves it to Trash once online |
|
||||
| triageNext | pass | pass | pass | n/a | pass | pass | pass | local only, no server call |
|
||||
| rsvp | pass | pass | pass | pass (unit test) | not run | not run | call passes, see 1.0 below | offline: the invitation stays with "Couldn't send your answer"; the retry accepts it. DBL and UPD not run on the device: they go through the same serial lane and pending overlay as archive and the tick, and `actions.test.ts` covers both |
|
||||
| toggleTask | pass | pass | pass | pass (unit test) | pass | not run | call passes, see 1.0 below | two taps 150 ms apart send `completed` twice; offline the tick reverts with a notice naming the task, and tapping the notice once online sends one `CalendarEvent/set` and the task shows done. UPD not run on the device (see rsvp) |
|
||||
| any button | pass | pass | – | – | – | – | – | request codes unique (107 click intents, 107 codes) |
|
||||
|
||||
DBL for archive: two taps 150 ms apart still both name the message the
|
||||
drawing showed (the second is a harmless repeat); taps a second apart act on
|
||||
consecutive messages. UPD: with `Email/set` delayed 4 s, two archives 1.5 s
|
||||
apart leave the card on "5 of 5" for the whole run and nothing comes back
|
||||
(before: the first archive's refresh brought the second message back). Trash
|
||||
behaves the same: a double tap names the shown message twice, and two
|
||||
deletes 1.5 s apart with `Email/set` delayed move two different messages
|
||||
and nothing comes back.
|
||||
|
||||
### Deep links
|
||||
|
||||
| Link | FG | BG | COLD | OFF | ACC2 | 1.0 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| inbox, unified, scheduled, search, compose | pass | pass | pass | pass | n/a | pass |
|
||||
| message | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| reply | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| draft | pass | pass | pass | only if opened before (L4) | pass | not run (same route as message) |
|
||||
| compose with to + subject | pass | pass | pass | not run (no server involved) | n/a | pass |
|
||||
| calendar | pass | pass | pass | pass (no crash) | n/a | opens; see 1.0 below |
|
||||
| event | pass | pass | pass | opens the Calendar tab; the event needs the server | pass (switches account) | see 1.0 below |
|
||||
| contacts, settings | pass | pass | pass | pass (Vacation settings say the server does not support it while offline) | n/a | pass |
|
||||
| files | pass | pass | pass | pass (no crash; the screen shows the raw network error) | n/a | pass |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result |
|
||||
|---|---|
|
||||
| Account switched in the app, then Home | pass: the widgets show the new account within a second, both directions |
|
||||
| App goes to the background | pass |
|
||||
| Cold periodic update | pass: mail, calendar, tasks, files and scheduled (`appDataAt` set) |
|
||||
| OAuth account with an expired access token, cold | pass: userb's token had expired an hour after sign-in; the cold refresh renewed it and loaded userb's inbox preview |
|
||||
|
||||
### Stalwart 1.0
|
||||
|
||||
- Mail actions and links pass: the archive race, a refused write with its
|
||||
notice and retry, trash, triage next, message and reply links cold and
|
||||
backgrounded, and the inbox, unified, scheduled, search, compose,
|
||||
calendar, contacts, files and settings links.
|
||||
- The app on main does not load calendar events or tasks from 1.0: its
|
||||
`CalendarEvent/get` asks for `recurrenceOverrides` together with
|
||||
`utcStart`/`utcEnd`, which 1.0 refuses as a whole (`invalidArguments`).
|
||||
The unmerged `feat/stalwart-1.0` branch fixes that. Until it is merged the
|
||||
calendar and task widgets are empty against 1.0. The widgets' own calls
|
||||
were sent to 1.0 directly: the task update (`progress`,
|
||||
`percentComplete`) and the RSVP patch both come back `updated`.
|
||||
|
||||
### Causes and their fixes
|
||||
|
||||
| Cause | Commit |
|
||||
|---|---|
|
||||
| A0 dead buttons (shared request codes) | `fix: stop widget buttons from going dead when two are drawn in the same millisecond` |
|
||||
| A3 refresh brings an archived message back, lost concurrent edits, out-of-order redraws | `fix: keep a widget archive when a second tap's refresh lands before it` |
|
||||
| A1, A5 failures looked like success, no feedback | the commit above (a failed op is dropped) and `feat: show on the widget when an action could not reach the server` |
|
||||
| A2, A4 tick flips back, item of another account | `fix: send widget RSVPs and task ticks to the item the tap was made on` |
|
||||
| A6 22dp tick | `fix: make widget task ticks easy to hit` |
|
||||
| R1 no refresh on leaving the app | `fix: refresh the widgets when the app goes to the background`, `fix: finish the widget refresh after leaving the app` |
|
||||
| R2 cold refresh skipped calendar and tasks | `fix: load calendar and tasks into the widgets when the app is not running` |
|
||||
| L3 offline crash from calendar and files widgets | `fix: keep the app from closing when a calendar widget opens it offline`, `fix: keep the app from closing when a files widget opens it offline` |
|
||||
| L5 event links without account | `fix: open a widget's event in the account it belongs to` |
|
||||
|
||||
## Phase 3: layout review
|
||||
|
||||
All 32 widgets were drawn through a temporary in-app gallery
|
||||
(`WidgetPreview`, removed again) at their smallest, default and largest
|
||||
(4x5) size with sample data, light and dark, German, very long names and
|
||||
counts (five-digit counts, 50-character names and subjects), empty data,
|
||||
and the loading, signed-out, not-yet-loaded and unsupported states, and
|
||||
with the tap areas highlighted. Fixed:
|
||||
|
||||
- The Inbox header at two cells pushed the compose button off; header
|
||||
titles now give way to the buttons, and the search icon goes at that
|
||||
width.
|
||||
- A long sender pushed the triage card's time and position out.
|
||||
- German labels were cut on the triage buttons ("Archivie", "Lösc"); they
|
||||
drop to icons when they do not fit.
|
||||
- Five-digit counts overflowed Unread count, Tag and Mail and next, and the
|
||||
total wrapped a character per line; counts from 10,000 are shortened.
|
||||
- "ungelesen" was cut in Mail and next; it moves under the number.
|
||||
- Buttons under 48dp (32–36dp) get a transparent 48dp tap area.
|
||||
- The Month arrows and the Vacation switch looked like controls but only
|
||||
opened the app.
|
||||
- Folder counts with no folders showed a bare heading.
|
||||
- A finished task past its due date read "Overdue".
|
||||
- The triage card and Latest message did not use their height for the
|
||||
preview.
|
||||
- A failure notice about a long task title cut off "Tap to try again".
|
||||
- Widget strings were English in every language; German added.
|
||||
- Avatars showed initials only, worked out differently from the webmail.
|
||||
Initials now follow the webmail everywhere in the app (first letters of
|
||||
the first and last name, leading punctuation and emoji skipped), and the
|
||||
widget avatars show the contact photo or the sender's logo first, as the
|
||||
app's list does. Logos come from the same favicon service, are fetched
|
||||
when the snapshot is built and are cached for offline drawing; a logo
|
||||
over 40 KB, or one Android cannot decode, leaves the initials.
|
||||
- The corners were rounder (22dp) than the launcher's own widgets; they are
|
||||
16dp now, previews included.
|
||||
|
||||
Checked and left as they are:
|
||||
|
||||
- Dark theme: no white fallbacks; the primary is white as in the webmail.
|
||||
- Large sizes of the 2x2 widgets are mostly empty space. A resize limit
|
||||
(`maxResizeWidth`) is given in dp, and on launchers with large cells it
|
||||
would pin a widget below two cells, so there is none.
|
||||
- RTL is not supported: the widget library does not mirror layouts, and
|
||||
there are no right-to-left widget strings.
|
||||
- Mail and next's chip for the following event is 24dp tall; the whole
|
||||
column also opens the calendar.
|
||||
|
||||
## Open
|
||||
|
||||
- A0 cannot be shown on this emulator, where each registration takes
|
||||
3–10 ms; the fix is checked by the request codes being unique.
|
||||
- Calendar and task widgets against Stalwart 1.0 wait for
|
||||
`feat/stalwart-1.0` (see above).
|
||||
- Offline message links need the offline cache; links for the other account
|
||||
need the server for the switch (L4).
|
||||
- Widget strings exist in English and German only.
|
||||
- RSVP double tap and update race, and the tick's update race, were not run
|
||||
on the device; unit tests cover them.
|
||||
- The Files screen opened offline shows the raw network error instead of an
|
||||
offline message (app screen, not a widget).
|
||||
@@ -6,6 +6,7 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
||||
import { markUiStarted } from './src/widgets/ui-presence';
|
||||
|
||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||
// from BulwarkMessagingService on an FCM data message. Must be registered
|
||||
@@ -15,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,11 +32,14 @@ AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import(
|
||||
|
||||
// Home-screen widgets (Android): every widget event runs this headless task.
|
||||
registerWidgetTaskHandler(widgetTaskHandler);
|
||||
// ...and leaving the app refreshes them in one (BulwarkWidgetRefreshService).
|
||||
AppRegistry.registerHeadlessTask('BulwarkWidgetRefresh', () => refreshWidgetsInBackground);
|
||||
|
||||
// A headless push task evaluates this file too, so App is required when the
|
||||
// UI first renders instead of imported above: importing it here would load
|
||||
// every screen, store and icon before the task could run.
|
||||
function Root() {
|
||||
markUiStarted();
|
||||
const App = (require('./App') as typeof import('./App')).default;
|
||||
return createElement(App);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "رابط",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
|
||||
"tap_to_create": "اضغط + لإنشاء حدث"
|
||||
},
|
||||
"fab": {
|
||||
"event": "حدث",
|
||||
"task": "مهمة"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "ملاحظات، جدول أعمال…",
|
||||
"location_placeholder": "المكان أو العنوان",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "لم تعد هذه المهمة متاحة.",
|
||||
"save_error": "فشل حفظ المهمة",
|
||||
"update_error": "فشل تحديث المهمة"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "الجدول",
|
||||
"three_days": "3 أيام"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enllaç",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Res planificat. Toca per crear.",
|
||||
"tap_to_create": "Toqueu + per crear-ne un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esdeveniment",
|
||||
"task": "Tasca"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre del dia…",
|
||||
"location_placeholder": "Lloc o adreça",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Aquesta tasca ja no està disponible.",
|
||||
"save_error": "No s'ha pogut desar la tasca",
|
||||
"update_error": "No s'ha pogut actualitzar la tasca"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 dies"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
|
||||
"severity_deprecated": "Obligatòria",
|
||||
"severity_security": "Seguretat",
|
||||
"title": "Actualitzacions de l'aplicació"
|
||||
"title": "Actualitzacions de l'aplicació",
|
||||
"verifying": "S'està verificant…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.",
|
||||
"tap_to_create": "Událost vytvoříte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Událost",
|
||||
"task": "Úkol"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Místo nebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Tento úkol už není k dispozici.",
|
||||
"save_error": "Úkol se nepodařilo uložit",
|
||||
"update_error": "Úkol se nepodařilo aktualizovat"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dny"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečení",
|
||||
"title": "Aktualizace aplikace"
|
||||
"title": "Aktualizace aplikace",
|
||||
"verifying": "Ověřování…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Intet planlagt. Tryk for at oprette.",
|
||||
"tap_to_create": "Tryk på + for at oprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Begivenhed",
|
||||
"task": "Opgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Noter, dagsorden…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne opgave er ikke længere tilgængelig.",
|
||||
"save_error": "Kunne ikke gemme opgaven",
|
||||
"update_error": "Kunne ikke opdatere opgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Tidsplan",
|
||||
"three_days": "3 dage"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
|
||||
"severity_deprecated": "Påkrævet",
|
||||
"severity_security": "Sikkerhed",
|
||||
"title": "Appopdateringer"
|
||||
"title": "Appopdateringer",
|
||||
"verifying": "Verificerer…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nichts geplant. Zum Erstellen tippen.",
|
||||
"tap_to_create": "Tippen Sie auf +, um einen zu erstellen"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Termin",
|
||||
"task": "Aufgabe"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notizen, Tagesordnung…",
|
||||
"location_placeholder": "Ort oder Adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
|
||||
"save_error": "Aufgabe konnte nicht gespeichert werden",
|
||||
"update_error": "Aufgabe konnte nicht aktualisiert werden"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Terminübersicht",
|
||||
"three_days": "3 Tage"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,198 @@
|
||||
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
|
||||
"severity_deprecated": "Erforderlich",
|
||||
"severity_security": "Sicherheit",
|
||||
"title": "App-Updates"
|
||||
"title": "App-Updates",
|
||||
"verifying": "Wird überprüft…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
"empty": "Keine neuen Anhänge",
|
||||
"from_mail": "aus E-Mail",
|
||||
"title": "Anhänge",
|
||||
"unknown_sender": "Unbekannter Absender"
|
||||
},
|
||||
"birthdays": {
|
||||
"empty": "Keine Geburtstage in Sicht",
|
||||
"empty_body": "Geburtstage kommen aus Ihren Kontakten.",
|
||||
"in_days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"send_wishes": "Gratulieren",
|
||||
"title": "Geburtstage",
|
||||
"wishes_subject": "Alles Gute zum Geburtstag!"
|
||||
},
|
||||
"calendar": {
|
||||
"agenda": "Agenda",
|
||||
"agenda_more": "{day}: {events}",
|
||||
"all_day": "Ganztägig",
|
||||
"join": "Teilnehmen",
|
||||
"join_named": "{name} beitreten",
|
||||
"more_this_week": "{count, plural, one {# weiterer diese Woche} other {# weitere diese Woche}}",
|
||||
"no_more_today": "Heute keine Termine mehr",
|
||||
"nothing_planned": "Nichts im Kalender",
|
||||
"now": "Jetzt",
|
||||
"open": "Öffnen",
|
||||
"running_late": "Verspäte mich",
|
||||
"running_late_body": "Ich komme ein paar Minuten später zu {title}.",
|
||||
"running_late_subject": "Verspäte mich: {title}",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"unit_days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"unit_hours": "{count, plural, one {Std.} other {Std.}}",
|
||||
"unit_minutes": "{count, plural, one {Min.} other {Min.}}",
|
||||
"unsupported": "Kalender ist auf diesem Server nicht verfügbar",
|
||||
"untitled": "(ohne Titel)",
|
||||
"video_call": "Videoanruf"
|
||||
},
|
||||
"countdown": {
|
||||
"days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"empty": "Keine anstehenden Termine"
|
||||
},
|
||||
"draft": {
|
||||
"edited_at": "Bearbeitet {when}",
|
||||
"edited_minutes": "{count, plural, one {Vor # Min. bearbeitet} other {Vor # Min. bearbeitet}}",
|
||||
"edited_now": "Gerade bearbeitet",
|
||||
"empty": "Keine Entwürfe",
|
||||
"empty_body": "Nachrichten, die Sie beginnen und nicht senden, warten hier.",
|
||||
"keep_writing": "Weiterschreiben",
|
||||
"title": "Entwürfe"
|
||||
},
|
||||
"files": {
|
||||
"empty": "Noch keine Dateien",
|
||||
"recent": "Zuletzt geändert",
|
||||
"unsupported": "Dateien sind auf diesem Server nicht verfügbar"
|
||||
},
|
||||
"free": {
|
||||
"busy_until": "Belegt bis {time}",
|
||||
"free_rest": "Den Rest des Tages frei",
|
||||
"free_until": "Frei bis {time}",
|
||||
"mail": "Freie Zeiten mailen",
|
||||
"mail_subject": "Meine freien Zeiten diese Woche",
|
||||
"no_calendar": "Kein Kalender in diesem Konto",
|
||||
"then": "danach {title}",
|
||||
"then_free": "danach frei"
|
||||
},
|
||||
"hub": {
|
||||
"all_clear": "Alles erledigt",
|
||||
"mail_unread": "E-Mail · {count} ungelesen",
|
||||
"next": "Als Nächstes",
|
||||
"next_on": "Als Nächstes · {day}",
|
||||
"no_events": "Keine anstehenden Termine",
|
||||
"now": "Jetzt",
|
||||
"tasks_due": "Aufgaben · {count} fällig"
|
||||
},
|
||||
"invites": {
|
||||
"accept": "Zusagen",
|
||||
"decline": "Absagen",
|
||||
"empty": "Keine offenen Einladungen",
|
||||
"label": "Einladung · {name}",
|
||||
"label_plain": "Einladung",
|
||||
"maybe": "Vielleicht",
|
||||
"position": "{index} von {count}"
|
||||
},
|
||||
"mail": {
|
||||
"all_accounts": "Alle Konten",
|
||||
"all_caught_up": "Alles gelesen",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"empty_all_accounts": "Ihre Posteingänge sind leer",
|
||||
"empty_inbox": "Ihr Posteingang ist leer",
|
||||
"folders": "Ordner",
|
||||
"in_outbox": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}",
|
||||
"in_outbox_short": "{count} warten",
|
||||
"inbox": "Posteingang",
|
||||
"more_unread": "+{count} weitere ungelesen",
|
||||
"no_folders": "Keine Ordner",
|
||||
"no_starred": "Keine markierten E-Mails",
|
||||
"no_starred_body": "Markieren Sie eine Nachricht mit einem Stern, um sie hier zu behalten.",
|
||||
"no_subject": "(kein Betreff)",
|
||||
"no_unread": "Keine ungelesenen Nachrichten im Posteingang.",
|
||||
"position": "{index} von {count}",
|
||||
"reply": "Antworten",
|
||||
"scheduled": "Geplant",
|
||||
"starred": "Markiert",
|
||||
"unread": "ungelesen"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Archivieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"mark_read": "Als gelesen markieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"rsvp": "Antwort nicht gesendet. Tippen zum Wiederholen.",
|
||||
"task": "„{title}“ nicht aktualisiert. Tippen zum Wiederholen.",
|
||||
"trash": "Löschen fehlgeschlagen. Tippen zum Wiederholen."
|
||||
},
|
||||
"people": {
|
||||
"empty": "Noch keine Favoriten",
|
||||
"empty_body": "Die Personen, von denen Sie am meisten hören, erscheinen hier.",
|
||||
"favourites": "Favoriten"
|
||||
},
|
||||
"relative": {
|
||||
"days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"hours": "in {count} Std.",
|
||||
"minutes": "in {count} Min.",
|
||||
"now": "jetzt"
|
||||
},
|
||||
"scheduled": {
|
||||
"day_at": "{day}, {time}",
|
||||
"empty": "Nichts geplant",
|
||||
"empty_body": "Nachrichten, die Sie später senden lassen, erscheinen hier.",
|
||||
"no_recipient": "(kein Empfänger)",
|
||||
"sending": "Wird gesendet",
|
||||
"to": "An: {name}",
|
||||
"today_at": "Heute, {time}",
|
||||
"tomorrow_at": "Morgen, {time}",
|
||||
"waiting": "Wartet auf Netz",
|
||||
"waiting_body": "Offline vorgenommene Änderungen werden gesendet, sobald Sie wieder online sind."
|
||||
},
|
||||
"search": {
|
||||
"no_recent": "Ihre letzten Suchen erscheinen hier.",
|
||||
"placeholder": "E-Mails durchsuchen…",
|
||||
"recent": "Letzte Suchen"
|
||||
},
|
||||
"shortcuts": {
|
||||
"calendar": "Kalender",
|
||||
"compose": "Verfassen",
|
||||
"contacts": "Kontakte",
|
||||
"files": "Dateien"
|
||||
},
|
||||
"state": {
|
||||
"error": "Widget kann nicht angezeigt werden",
|
||||
"loading": "Wird geladen…",
|
||||
"open_app": "Öffnen Sie die App einmal, falls dies leer bleibt.",
|
||||
"open_to_load": "Öffnen Sie Bulwark, um dies zu laden",
|
||||
"signed_out": "Bei Bulwark anmelden"
|
||||
},
|
||||
"storage": {
|
||||
"no_limit": "Kein Speicherlimit",
|
||||
"title": "Speicher",
|
||||
"unavailable": "Keine Angaben zum Speicher",
|
||||
"used_of": "{used} von {total}"
|
||||
},
|
||||
"tag": {
|
||||
"empty": "Noch keine Tags",
|
||||
"empty_body": "Tags, die Sie in der App vergeben, erscheinen hier.",
|
||||
"messages": "{count, plural, one {Nachricht} other {Nachrichten}}",
|
||||
"total": "{count, plural, one {# Nachricht} other {# Nachrichten}}"
|
||||
},
|
||||
"tasks": {
|
||||
"all_done": "Alles erledigt",
|
||||
"empty": "Keine offenen Aufgaben",
|
||||
"mark_complete": "Als erledigt markieren",
|
||||
"mark_incomplete": "Als nicht erledigt markieren",
|
||||
"no_title": "(ohne Titel)",
|
||||
"off": "Aufgaben sind aus",
|
||||
"off_body": "Schalten Sie Aufgaben in den Kalendereinstellungen der App ein",
|
||||
"open_count": "{count} offen",
|
||||
"overdue": "Überfällig",
|
||||
"still_open": "{count} noch offen",
|
||||
"title": "Aufgaben",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen"
|
||||
},
|
||||
"vacation": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"subject": "Betreff: {subject}",
|
||||
"title": "Abwesenheitsnotiz",
|
||||
"unsupported": "Abwesenheitsnotizen sind nicht verfügbar",
|
||||
"until": "Bis {date}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"indigo": "Indigo",
|
||||
"teal": "Teal"
|
||||
},
|
||||
"detail": {
|
||||
"location_hint": "Opens the location; long-press to copy it"
|
||||
},
|
||||
"drawer": {
|
||||
"my_calendars": "My calendars",
|
||||
"no_calendars": "No calendars yet.",
|
||||
@@ -25,8 +28,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nothing planned. Tap to create.",
|
||||
"tap_to_create": "Tap + to create one"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Event",
|
||||
"task": "Task"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, agenda…",
|
||||
"location_placeholder": "Place or address",
|
||||
@@ -127,6 +135,10 @@
|
||||
"not_found": "This task is no longer available.",
|
||||
"save_error": "Failed to save task",
|
||||
"update_error": "Failed to update task"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Schedule",
|
||||
"three_days": "3 days"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1184,7 +1196,8 @@
|
||||
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
|
||||
"severity_deprecated": "Required",
|
||||
"severity_security": "Security",
|
||||
"title": "App updates"
|
||||
"title": "App updates",
|
||||
"verifying": "Verifying…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
@@ -1282,6 +1295,7 @@
|
||||
"in_outbox_short": "{count} waiting",
|
||||
"inbox": "Inbox",
|
||||
"more_unread": "+{count} more unread",
|
||||
"no_folders": "No folders to show",
|
||||
"no_starred": "No starred mail",
|
||||
"no_starred_body": "Star a message to keep it here.",
|
||||
"no_subject": "(no subject)",
|
||||
@@ -1292,6 +1306,13 @@
|
||||
"starred": "Starred",
|
||||
"unread": "unread"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Couldn't archive. Tap to try again.",
|
||||
"mark_read": "Couldn't mark as read. Tap to try again.",
|
||||
"rsvp": "Couldn't send your answer. Tap to try again.",
|
||||
"task": "Couldn't update “{title}”. Tap to try again.",
|
||||
"trash": "Couldn't delete. Tap to try again."
|
||||
},
|
||||
"people": {
|
||||
"empty": "No favourites yet",
|
||||
"empty_body": "The people you hear from most show up here.",
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enlace",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planificado. Toca para crear.",
|
||||
"tap_to_create": "Toca + para crear uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarea"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, orden del día…",
|
||||
"location_placeholder": "Lugar o dirección",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarea ya no está disponible.",
|
||||
"save_error": "No se pudo guardar la tarea",
|
||||
"update_error": "No se pudo actualizar la tarea"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 días"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
|
||||
"severity_deprecated": "Obligatoria",
|
||||
"severity_security": "Seguridad",
|
||||
"title": "Actualizaciones de la app"
|
||||
"title": "Actualizaciones de la app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "لینک",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "برنامهای نیست. برای ایجاد ضربه بزنید.",
|
||||
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
|
||||
},
|
||||
"fab": {
|
||||
"event": "رویداد",
|
||||
"task": "کار"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "یادداشتها، دستور جلسه…",
|
||||
"location_placeholder": "مکان یا نشانی",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "این وظیفه دیگر در دسترس نیست.",
|
||||
"save_error": "ذخیره وظیفه ناموفق بود",
|
||||
"update_error": "بهروزرسانی وظیفه ناموفق بود"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "برنامه",
|
||||
"three_days": "۳ روز"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lien",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Rien de prévu. Touchez pour créer.",
|
||||
"tap_to_create": "Appuyez sur + pour en créer un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Événement",
|
||||
"task": "Tâche"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre du jour…",
|
||||
"location_placeholder": "Lieu ou adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Cette tâche n'est plus disponible.",
|
||||
"save_error": "Échec de l'enregistrement de la tâche",
|
||||
"update_error": "Échec de la mise à jour de la tâche"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 jours"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
|
||||
"severity_deprecated": "Requise",
|
||||
"severity_security": "Sécurité",
|
||||
"title": "Mises à jour de l'application"
|
||||
"title": "Mises à jour de l'application",
|
||||
"verifying": "Vérification…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "קישור",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
|
||||
"tap_to_create": "הקש על + כדי ליצור"
|
||||
},
|
||||
"fab": {
|
||||
"event": "אירוע",
|
||||
"task": "משימה"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "הערות, סדר יום…",
|
||||
"location_placeholder": "מקום או כתובת",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "המשימה הזו אינה זמינה עוד.",
|
||||
"save_error": "שמירת המשימה נכשלה",
|
||||
"update_error": "עדכון המשימה נכשל"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "לוח זמנים",
|
||||
"three_days": "3 ימים"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Hivatkozás",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.",
|
||||
"tap_to_create": "Esemény létrehozásához koppints a + gombra"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esemény",
|
||||
"task": "Feladat"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Jegyzetek, napirend…",
|
||||
"location_placeholder": "Hely vagy cím",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Ez a feladat már nem érhető el.",
|
||||
"save_error": "Nem sikerült menteni a feladatot",
|
||||
"update_error": "Nem sikerült frissíteni a feladatot"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Ütemezés",
|
||||
"three_days": "3 nap"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
|
||||
"severity_deprecated": "Kötelező",
|
||||
"severity_security": "Biztonsági",
|
||||
"title": "Alkalmazásfrissítések"
|
||||
"title": "Alkalmazásfrissítések",
|
||||
"verifying": "Ellenőrzés…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niente in programma. Tocca per creare.",
|
||||
"tap_to_create": "Tocca + per crearne uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Attività"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, ordine del giorno…",
|
||||
"location_placeholder": "Luogo o indirizzo",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Questa attività non è più disponibile.",
|
||||
"save_error": "Impossibile salvare l'attività",
|
||||
"update_error": "Impossibile aggiornare l'attività"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programma",
|
||||
"three_days": "3 giorni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
|
||||
"severity_deprecated": "Obbligatorio",
|
||||
"severity_security": "Sicurezza",
|
||||
"title": "Aggiornamenti dell'app"
|
||||
"title": "Aggiornamenti dell'app",
|
||||
"verifying": "Verifica in corso…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "リンク",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "予定はありません。タップして作成",
|
||||
"tap_to_create": "+ をタップして作成"
|
||||
},
|
||||
"fab": {
|
||||
"event": "予定",
|
||||
"task": "タスク"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "メモ、議題…",
|
||||
"location_placeholder": "場所または住所",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "このタスクは利用できなくなりました。",
|
||||
"save_error": "タスクの保存に失敗しました",
|
||||
"update_error": "タスクの更新に失敗しました"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "スケジュール",
|
||||
"three_days": "3 日"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "링크",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
|
||||
"tap_to_create": "+를 눌러 만들어 보세요"
|
||||
},
|
||||
"fab": {
|
||||
"event": "일정",
|
||||
"task": "할 일"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "메모, 안건…",
|
||||
"location_placeholder": "장소 또는 주소",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "이 할 일을 더 이상 찾을 수 없어요.",
|
||||
"save_error": "할 일을 저장하지 못했어요",
|
||||
"update_error": "할 일을 업데이트하지 못했어요"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "일정",
|
||||
"three_days": "3일"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Saite",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.",
|
||||
"tap_to_create": "Pieskarieties +, lai izveidotu notikumu"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Notikums",
|
||||
"task": "Uzdevums"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Piezīmes, darba kārtība…",
|
||||
"location_placeholder": "Vieta vai adrese",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Šis uzdevums vairs nav pieejams.",
|
||||
"save_error": "Neizdevās saglabāt uzdevumu",
|
||||
"update_error": "Neizdevās atjaunināt uzdevumu"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Grafiks",
|
||||
"three_days": "3 dienas"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
|
||||
"severity_deprecated": "Obligāts",
|
||||
"severity_security": "Drošība",
|
||||
"title": "Lietotnes atjauninājumi"
|
||||
"title": "Lietotnes atjauninājumi",
|
||||
"verifying": "Pārbauda…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Холбоос",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
|
||||
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Үйл явдал",
|
||||
"task": "Даалгавар"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…",
|
||||
"location_placeholder": "Газар эсвэл хаяг",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Энэ даалгавар байхгүй болсон.",
|
||||
"save_error": "Даалгаврыг хадгалж чадсангүй",
|
||||
"update_error": "Даалгаврыг шинэчилж чадсангүй"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Хуваарь",
|
||||
"three_days": "3 өдөр"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lenke",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ingenting planlagt. Trykk for å opprette.",
|
||||
"tap_to_create": "Trykk på + for å opprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Hendelse",
|
||||
"task": "Oppgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notater, agenda…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne oppgaven er ikke lenger tilgjengelig.",
|
||||
"save_error": "Kunne ikke lagre oppgaven",
|
||||
"update_error": "Kunne ikke oppdatere oppgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Timeplan",
|
||||
"three_days": "3 dager"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
|
||||
"severity_deprecated": "Påkrevd",
|
||||
"severity_security": "Sikkerhet",
|
||||
"title": "Appoppdateringer"
|
||||
"title": "Appoppdateringer",
|
||||
"verifying": "Verifiserer…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niets gepland. Tik om aan te maken.",
|
||||
"tap_to_create": "Tik op + om er een te maken"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Afspraak",
|
||||
"task": "Taak"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notities, agendapunten…",
|
||||
"location_placeholder": "Plaats of adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Deze taak is niet meer beschikbaar.",
|
||||
"save_error": "Taak opslaan mislukt",
|
||||
"update_error": "Taak bijwerken mislukt"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 dagen"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
|
||||
"severity_deprecated": "Vereist",
|
||||
"severity_security": "Beveiliging",
|
||||
"title": "App-updates"
|
||||
"title": "App-updates",
|
||||
"verifying": "Controleren…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.",
|
||||
"tap_to_create": "Stuknij +, aby utworzyć wydarzenie"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Wydarzenie",
|
||||
"task": "Zadanie"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notatki, agenda…",
|
||||
"location_placeholder": "Miejsce lub adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "To zadanie nie jest już dostępne.",
|
||||
"save_error": "Nie udało się zapisać zadania",
|
||||
"update_error": "Nie udało się zaktualizować zadania"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Harmonogram",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
|
||||
"severity_deprecated": "Wymagana",
|
||||
"severity_security": "Bezpieczeństwo",
|
||||
"title": "Aktualizacje aplikacji"
|
||||
"title": "Aktualizacje aplikacji",
|
||||
"verifying": "Weryfikowanie…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planejado. Toque para criar.",
|
||||
"tap_to_create": "Toque em + para criar um"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarefa"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, pauta…",
|
||||
"location_placeholder": "Local ou endereço",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarefa não está mais disponível.",
|
||||
"save_error": "Falha ao salvar a tarefa",
|
||||
"update_error": "Falha ao atualizar a tarefa"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programação",
|
||||
"three_days": "3 dias"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
|
||||
"severity_deprecated": "Obrigatória",
|
||||
"severity_security": "Segurança",
|
||||
"title": "Atualizações do app"
|
||||
"title": "Atualizações do app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nimic planificat. Atinge pentru a crea.",
|
||||
"tap_to_create": "Atingeți + pentru a crea unul"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Eveniment",
|
||||
"task": "Sarcină"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, agendă…",
|
||||
"location_placeholder": "Loc sau adresă",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Această sarcină nu mai este disponibilă.",
|
||||
"save_error": "Sarcina nu a putut fi salvată",
|
||||
"update_error": "Sarcina nu a putut fi actualizată"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 zile"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
|
||||
"severity_deprecated": "Obligatorie",
|
||||
"severity_security": "Securitate",
|
||||
"title": "Actualizări aplicație"
|
||||
"title": "Actualizări aplicație",
|
||||
"verifying": "Se verifică…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Ссылка",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
|
||||
"tap_to_create": "Нажмите +, чтобы создать"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Мероприятие",
|
||||
"task": "Задача"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Заметки, повестка…",
|
||||
"location_placeholder": "Место или адрес",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Эта задача больше недоступна.",
|
||||
"save_error": "Не удалось сохранить задачу",
|
||||
"update_error": "Не удалось обновить задачу"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Расписание",
|
||||
"three_days": "3 дня"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.",
|
||||
"tap_to_create": "Udalosť vytvoríte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Udalosť",
|
||||
"task": "Úloha"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Miesto alebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Táto úloha už nie je k dispozícii.",
|
||||
"save_error": "Úlohu sa nepodarilo uložiť",
|
||||
"update_error": "Úlohu sa nepodarilo aktualizovať"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečenie",
|
||||
"title": "Aktualizácie aplikácie"
|
||||
"title": "Aktualizácie aplikácie",
|
||||
"verifying": "Overovanie…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Bağlantı",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.",
|
||||
"tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Etkinlik",
|
||||
"task": "Görev"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notlar, gündem…",
|
||||
"location_placeholder": "Yer veya adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Bu görev artık kullanılamıyor.",
|
||||
"save_error": "Görev kaydedilemedi",
|
||||
"update_error": "Görev güncellenemedi"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 gün"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
|
||||
"severity_deprecated": "Zorunlu",
|
||||
"severity_security": "Güvenlik",
|
||||
"title": "Uygulama güncellemeleri"
|
||||
"title": "Uygulama güncellemeleri",
|
||||
"verifying": "Doğrulanıyor…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Посилання",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
|
||||
"tap_to_create": "Натисніть +, щоб створити"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Подія",
|
||||
"task": "Завдання"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Нотатки, порядок денний…",
|
||||
"location_placeholder": "Місце або адреса",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Це завдання більше недоступне.",
|
||||
"save_error": "Не вдалося зберегти завдання",
|
||||
"update_error": "Не вдалося оновити завдання"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Розклад",
|
||||
"three_days": "3 дні"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "連結",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "沒有安排。輕觸即可建立。",
|
||||
"tap_to_create": "點選 + 即可建立"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活動",
|
||||
"task": "工作"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "備註、議程…",
|
||||
"location_placeholder": "地點或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任務已不存在。",
|
||||
"save_error": "儲存任務失敗",
|
||||
"update_error": "更新任務失敗"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "行程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "链接",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "暂无安排。点按即可创建。",
|
||||
"tap_to_create": "点按 + 创建"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活动",
|
||||
"task": "任务"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "备注、议程…",
|
||||
"location_placeholder": "地点或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任务已不存在。",
|
||||
"save_error": "保存任务失败",
|
||||
"update_error": "更新任务失败"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "日程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.75",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
@@ -3803,6 +3804,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -6181,6 +6189,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flow-enums-runtime": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
|
||||
@@ -6226,6 +6244,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -7300,6 +7333,33 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7312,6 +7372,29 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
@@ -7324,6 +7407,16 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -8601,6 +8694,138 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/patch-package/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -10264,6 +10489,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -10392,6 +10627,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -56,6 +57,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
index 1c12d1f..60f01e7 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
@@ -207,7 +207,7 @@ public class RNWidget {
|
||||
intent.putExtra("clickActionData", Arguments.toBundle(clickableView.getClickActionData()));
|
||||
PendingIntent pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
index ed1af56..12c3630 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
@@ -162,7 +162,7 @@ class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
|
||||
intent.putExtra("clickActionData", clickableArea.getBundle("clickActionData"));
|
||||
return PendingIntent.getBroadcast(
|
||||
mContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
index 2a34305..711503b 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
@@ -15,8 +15,23 @@ import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
import java.util.List;
|
||||
+import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class RNWidgetUtil {
|
||||
+ private static final AtomicInteger lastRequestCode = new AtomicInteger((int) System.currentTimeMillis() - 1);
|
||||
+
|
||||
+ /**
|
||||
+ * A request code no other click PendingIntent of this app holds. Every click intent has the same
|
||||
+ * action and component, so the request code alone tells them apart: a code shared by two buttons
|
||||
+ * (two registrations within one millisecond with the time as the code) made the second one cancel
|
||||
+ * the first, leaving a button that does nothing. Counting up from the time keeps the codes unique
|
||||
+ * in this process and clear of the ones an earlier process left on the launcher.
|
||||
+ */
|
||||
+ public static int nextClickRequestCode() {
|
||||
+ int now = (int) System.currentTimeMillis();
|
||||
+ return lastRequestCode.updateAndGet(last -> now - last > 0 ? now : last + 1);
|
||||
+ }
|
||||
+
|
||||
public static int getWidgetWidth(Context context, int widgetId) {
|
||||
boolean isPortrait = context.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT;
|
||||
if (isPortrait) {
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
|
||||
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted } from '../lib/trusted-senders';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -527,6 +528,8 @@ export default function EmailBodyView({
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const hasContacts = useHasContacts();
|
||||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
@@ -540,8 +543,8 @@ export default function EmailBodyView({
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
@@ -561,7 +564,7 @@ export default function EmailBodyView({
|
||||
const text = picked.text;
|
||||
const trusted = isSenderContentTrusted(senderEmail, {
|
||||
isLocallyTrusted: isSenderTrusted,
|
||||
syncEnabled: trustedSendersAddressBook,
|
||||
syncEnabled: syncTrustedSenders,
|
||||
trustedBookEmails: trustedSenderEmails,
|
||||
});
|
||||
|
||||
@@ -721,12 +724,11 @@ export default function EmailBodyView({
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
// Keep the local allow-list for instant effect, and file the sender in
|
||||
// the server-side "Trusted Senders" address book (synced across
|
||||
// devices) unless the user turned that off.
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook) {
|
||||
if (syncTrustedSenders) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
setAllowOnce(true);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -102,6 +102,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
);
|
||||
|
||||
const candidates = React.useMemo(() => {
|
||||
// The sheet is mounted with the Calendar tab, before a session may exist
|
||||
// (an offline cold start): nobody to list yet, and no own id to read.
|
||||
if (principals.length === 0) return [];
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -98,6 +98,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
||||
);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
// The sheet is mounted with the Files tab, before a session may exist
|
||||
// (an offline cold start): nobody to list yet, and no own id to read.
|
||||
if (principals.length === 0) return [];
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useHasContacts } from '../../lib/capabilities';
|
||||
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
interface TrustedRow {
|
||||
@@ -49,9 +50,8 @@ export function ContentSendersSettings() {
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
// Sync stays off until the user turns it on below (null = never chosen):
|
||||
// opening this pane must not change which senders the viewer trusts.
|
||||
const syncEnabled = !!trustedSendersAddressBook && hasContacts;
|
||||
// On unless the user turned it off below (null = never chosen).
|
||||
const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalOpen && syncEnabled) void loadTrustedSendersBook();
|
||||
@@ -179,7 +179,7 @@ export function ContentSendersSettings() {
|
||||
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={!!trustedSendersAddressBook}
|
||||
checked={syncEnabled}
|
||||
onChange={(enabled) => {
|
||||
updateSetting('trustedSendersAddressBook', enabled);
|
||||
if (enabled) void loadTrustedSendersBook();
|
||||
|
||||
@@ -32,6 +32,7 @@ function installButtonLabel(
|
||||
): string {
|
||||
if (!installing) return t('updates.install', 'Install');
|
||||
if (progress?.phase === 'installing') return t('updates.installing', 'Installing…');
|
||||
if (progress?.phase === 'verifying') return t('updates.verifying', 'Verifying…');
|
||||
if (progress?.progress != null) {
|
||||
return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) });
|
||||
}
|
||||
@@ -213,10 +214,12 @@ export function UpdatesSettings() {
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
// The download is complete once it is verifying, so the
|
||||
// bar stays full instead of restarting at 0.
|
||||
width:
|
||||
installProgress.phase === 'installing'
|
||||
? '100%'
|
||||
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
|
||||
installProgress.phase === 'downloading'
|
||||
? `${Math.round((installProgress.progress ?? 0) * 100)}%`
|
||||
: '100%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -224,7 +227,9 @@ export function UpdatesSettings() {
|
||||
<Text style={styles.progressLabel}>
|
||||
{installProgress.phase === 'installing'
|
||||
? t('updates.opening_installer', 'Opening installer…')
|
||||
: formatProgressLabel(installProgress, t)}
|
||||
: installProgress.phase === 'verifying'
|
||||
? t('updates.verifying', 'Verifying…')
|
||||
: formatProgressLabel(installProgress, t)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getContactPhotoForEmail, getContactPhotoIndex } from '../avatar-utils';
|
||||
import { getAccountInitials, getContactPhotoForEmail, getContactPhotoIndex, getEmailInitials } from '../avatar-utils';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
|
||||
function card(id: string, emails: string[], photo?: string, mediaType?: string): ContactCard {
|
||||
@@ -52,3 +52,25 @@ describe('contact photo avatars (#54)', () => {
|
||||
expect(index.has('b@x.org')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initials, as the webmail Avatar works them out', () => {
|
||||
it('skips punctuation and symbols in front of a name part', () => {
|
||||
expect(getEmailInitials('"Sofia" Russo')).toBe('SR');
|
||||
expect(getEmailInitials('(Dr.) Smith')).toBe('DS');
|
||||
expect(getEmailInitials('Sofia - Russo')).toBe('SR');
|
||||
expect(getEmailInitials('@team updates')).toBe('TU');
|
||||
});
|
||||
|
||||
it('takes two letters of a single name and falls back to the address', () => {
|
||||
expect(getEmailInitials('Madonna')).toBe('MA');
|
||||
expect(getEmailInitials('', 'bob@example.org')).toBe('B');
|
||||
expect(getEmailInitials('')).toBe('?');
|
||||
expect(getEmailInitials('!!!')).toBe('!!');
|
||||
});
|
||||
|
||||
it('works the same for account avatars, which the webmail draws with the same Avatar', () => {
|
||||
expect(getAccountInitials('Ada Lovelace', 'ada@example.org')).toBe('AL');
|
||||
expect(getAccountInitials('Ada', 'ada@example.org')).toBe('AD');
|
||||
expect(getAccountInitials('', 'ada@example.org')).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const files = new Map<string, Uint8Array>();
|
||||
const digest = vi.fn();
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }));
|
||||
vi.mock('expo-file-system', () => ({
|
||||
File: class {
|
||||
constructor(private uri: string) {}
|
||||
async bytes() { return files.get(this.uri) ?? new Uint8Array(); }
|
||||
},
|
||||
Paths: { cache: 'file:///cache' },
|
||||
}));
|
||||
vi.mock('expo-file-system/legacy', () => ({}));
|
||||
vi.mock('expo-intent-launcher', () => ({}));
|
||||
vi.mock('expo-crypto', () => ({
|
||||
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||
digest: (...args: unknown[]) => digest(...args),
|
||||
}));
|
||||
|
||||
import { hashFile } from '../install-update';
|
||||
|
||||
const sha256Hex = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
describe('hashFile', () => {
|
||||
const apk = new TextEncoder().encode('not really an apk');
|
||||
|
||||
beforeEach(() => {
|
||||
files.clear();
|
||||
files.set('file:///cache/app.apk', apk);
|
||||
digest.mockReset();
|
||||
});
|
||||
|
||||
it('hashes the file natively in one call', async () => {
|
||||
digest.mockImplementation(async (_alg: string, data: Uint8Array) => {
|
||||
const out = createHash('sha256').update(data).digest();
|
||||
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength);
|
||||
});
|
||||
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
|
||||
expect(digest).toHaveBeenCalledTimes(1);
|
||||
expect(digest).toHaveBeenCalledWith('SHA-256', apk);
|
||||
});
|
||||
|
||||
it('falls back to the JS hasher when the native digest fails', async () => {
|
||||
digest.mockRejectedValue(new Error('unavailable'));
|
||||
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
|
||||
});
|
||||
});
|
||||