mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
thread(name = "bulwark-notification") {
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ object NotificationTapStore {
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +79,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
|
||||
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
|
||||
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
|
||||
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
|
||||
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Device sync (Android, #34): the contacts and calendar sync adapters run the
|
||||
// sync engine through this task, headless or next to the UI. The engine is
|
||||
// required on first use so an app start does not load it.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface Props {
|
||||
onMoreOptions: (draft: string) => void;
|
||||
/** Reflect `$answered` in the caller's cache. */
|
||||
onSent?: (email: Email) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +38,7 @@ interface Props {
|
||||
* to the sender with the original quoted, sent through the identity that
|
||||
* received the message. "More options" hands the text to the full composer.
|
||||
*/
|
||||
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) {
|
||||
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
@@ -147,6 +149,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findMeetingLink,
|
||||
isMeetingLabel,
|
||||
locationAction,
|
||||
mapsUrl,
|
||||
meetingProviderOf,
|
||||
primaryLocationName,
|
||||
unwrapSafeLink,
|
||||
} from '../event-links';
|
||||
import { isSafeExternalUrl } from '../open-url';
|
||||
|
||||
/**
|
||||
* A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves
|
||||
* the join URL in the description, next to help and dial-in links. The sheet
|
||||
* must still offer a single "Join" button, and a real address must open the
|
||||
* maps app.
|
||||
*/
|
||||
|
||||
const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d';
|
||||
|
||||
const TEAMS_DESCRIPTION = [
|
||||
'________________________________________________________________________________',
|
||||
'Réunion Microsoft Teams',
|
||||
'Rejoindre sur votre ordinateur, application mobile ou appareil de salle',
|
||||
`Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`,
|
||||
'ID de la réunion : 319 560 123 45',
|
||||
'Code secret : CTYa9',
|
||||
'Télécharger Teams<https://www.microsoft.com/fr-fr/microsoft-teams/download-app> | Rejoindre sur le web<https://www.microsoft.com/microsoft-teams/join-a-meeting>',
|
||||
'En savoir plus<https://aka.ms/JoinTeamsMeeting> | Options de réunion<https://teams.microsoft.com/meetingOptions/?organizerId=1>',
|
||||
].join('\n');
|
||||
|
||||
describe('findMeetingLink', () => {
|
||||
it('prefers the structured virtual location', () => {
|
||||
expect(findMeetingLink({
|
||||
virtualLocations: { v: { uri: 'https://zoom.us/j/123' } },
|
||||
description: TEAMS_DESCRIPTION,
|
||||
})).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false });
|
||||
});
|
||||
|
||||
it('digs the Teams join URL out of the description, skipping help links', () => {
|
||||
expect(findMeetingLink({
|
||||
locations: { l: { name: 'Réunion Microsoft Teams' } },
|
||||
description: TEAMS_DESCRIPTION,
|
||||
})).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true });
|
||||
});
|
||||
|
||||
it('recognises the short Teams, Meet and Zoom forms', () => {
|
||||
expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams');
|
||||
expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij');
|
||||
expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom');
|
||||
});
|
||||
|
||||
it('takes a meeting URL used as the location', () => {
|
||||
expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup');
|
||||
});
|
||||
|
||||
it('unwraps Outlook Safe Links', () => {
|
||||
const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`;
|
||||
expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN);
|
||||
});
|
||||
|
||||
it('ignores ordinary links', () => {
|
||||
expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull();
|
||||
expect(findMeetingLink({})).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to an openable non-web virtual location, never a script one', () => {
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'tel:+33123456789' } } }))
|
||||
.toEqual({ uri: 'tel:+33123456789', derived: false });
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull();
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull();
|
||||
});
|
||||
|
||||
// openExternalUrl refuses these schemes, so offering one gave a Join button
|
||||
// and a location tap that did nothing.
|
||||
it('never offers an app-scheme link the app cannot open', () => {
|
||||
for (const uri of ['msteams:/l/meetup-join/19%3a1', 'zoommtg://zoom.us/join?confno=1', 'sip:room@example.com']) {
|
||||
expect(findMeetingLink({ virtualLocations: { v: { uri } } })).toBeNull();
|
||||
expect(isSafeExternalUrl(uri)).toBe(false);
|
||||
}
|
||||
expect(findMeetingLink({
|
||||
virtualLocations: { a: { uri: 'msteams:/l/meetup-join/19%3a1' }, b: { uri: 'tel:+33123456789' } },
|
||||
})?.uri).toBe('tel:+33123456789');
|
||||
});
|
||||
|
||||
it('recognises the Zoom web-client join path', () => {
|
||||
expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom');
|
||||
});
|
||||
|
||||
it('skips a virtual location that is not a web URL', () => {
|
||||
expect(findMeetingLink({
|
||||
virtualLocations: { v: { uri: 'tel:+33123456789' } },
|
||||
description: 'https://meet.google.com/abc-defg-hij',
|
||||
})?.provider).toBe('Google Meet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('meetingProviderOf / unwrapSafeLink', () => {
|
||||
it('rejects look-alike hosts', () => {
|
||||
expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull();
|
||||
expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves non-wrapped URLs alone', () => {
|
||||
expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('locationAction', () => {
|
||||
const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true };
|
||||
|
||||
it('searches the maps for a postal address', () => {
|
||||
expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' });
|
||||
});
|
||||
|
||||
it('joins the meeting when the location only names the service', () => {
|
||||
expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN });
|
||||
expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' });
|
||||
});
|
||||
|
||||
it('opens a URL location', () => {
|
||||
expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' });
|
||||
});
|
||||
|
||||
it('only treats a bare service name as a meeting', () => {
|
||||
for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) {
|
||||
expect(isMeetingLabel(label)).toBe(true);
|
||||
}
|
||||
for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) {
|
||||
expect(isMeetingLabel(label)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsUrl', () => {
|
||||
it('uses the geo: intent on Android', () => {
|
||||
expect(mapsUrl('12 rue de la Paix,\n75002 Paris', 'android')).toBe('geo:0,0?q=12%20rue%20de%20la%20Paix%2C%2075002%20Paris');
|
||||
});
|
||||
|
||||
it('uses the Google Maps universal link elsewhere', () => {
|
||||
expect(mapsUrl('Gare de Lyon', 'ios')).toBe('https://www.google.com/maps/search/?api=1&query=Gare%20de%20Lyon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryLocationName', () => {
|
||||
it('trims and drops blank names', () => {
|
||||
expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined();
|
||||
expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
|
||||
const { mockSecureFetch } = vi.hoisted(() => ({ mockSecureFetch: vi.fn() }));
|
||||
vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch }));
|
||||
|
||||
import { redeemPairingCode, runWebmailHandoff } from '../oauth';
|
||||
|
||||
const WEBMAIL = 'https://webmail.example.com';
|
||||
const SERVER = 'https://mail.example.com';
|
||||
const ISSUER = 'https://id.example.net';
|
||||
const TOKEN_ENDPOINT = `${ISSUER}/token`;
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
let metadata: Record<string, unknown>;
|
||||
let bundle: Record<string, string>;
|
||||
let stateMismatch: boolean;
|
||||
|
||||
function response(url: string, data: unknown): Response {
|
||||
return { ok: true, status: 200, url, redirected: false, json: async () => data } as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
config = {
|
||||
jmapServerUrl: SERVER,
|
||||
oauthEnabled: true,
|
||||
oauthClientId: 'stalwart',
|
||||
oauthIssuerUrl: ISSUER,
|
||||
};
|
||||
metadata = { issuer: ISSUER, token_endpoint: TOKEN_ENDPOINT };
|
||||
bundle = {
|
||||
flow: 'oauth', server_url: SERVER, access_token: 'test-access',
|
||||
refresh_token: 'test-refresh', client_id: 'stalwart', token_endpoint: TOKEN_ENDPOINT,
|
||||
};
|
||||
stateMismatch = false;
|
||||
vi.mocked(WebBrowser.openAuthSessionAsync).mockImplementation(async (url) => ({
|
||||
type: 'success',
|
||||
url: `bulwarkmobile://auth/callback#${new URLSearchParams({
|
||||
...bundle,
|
||||
state: stateMismatch ? 'wrong-state' : new URL(url).searchParams.get('mobile_state')!,
|
||||
})}`,
|
||||
}));
|
||||
mockSecureFetch.mockImplementation(async (url: string) => {
|
||||
if (url === `${WEBMAIL}/api/auth/pair/redeem`) return response(url, bundle);
|
||||
if (url === `${WEBMAIL}/api/config`) return response(url, config);
|
||||
if (url === `${WEBMAIL}/api/auth/oauth/metadata`) return response(url, metadata);
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
for (const flow of ['handoff', 'pairing'] as const) {
|
||||
const login = () => flow === 'handoff'
|
||||
? runWebmailHandoff(WEBMAIL)
|
||||
: redeemPairingCode(WEBMAIL, 'test-pair-code');
|
||||
|
||||
describe(`${flow} external identity provider`, () => {
|
||||
it('accepts an external token endpoint verified through the selected webmail', async () => {
|
||||
await expect(login()).resolves.toMatchObject({
|
||||
flow: 'oauth', serverUrl: SERVER,
|
||||
tokens: { tokenEndpoint: TOKEN_ENDPOINT, clientId: 'stalwart', source: flow },
|
||||
});
|
||||
const discoveryCalls = mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem'));
|
||||
expect(discoveryCalls.map(([url]) => url)).toEqual([
|
||||
`${WEBMAIL}/api/config`, `${WEBMAIL}/api/auth/oauth/metadata`,
|
||||
]);
|
||||
for (const [, options] of discoveryCalls) {
|
||||
expect(options).toMatchObject({
|
||||
headers: { Accept: 'application/json' }, redirect: 'error', timeoutMs: 8000,
|
||||
});
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(options.body).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('also accepts a configured sibling-host provider', async () => {
|
||||
config.oauthIssuerUrl = metadata.issuer = 'https://id.example.com';
|
||||
metadata.token_endpoint = bundle.token_endpoint = 'https://id.example.com/token';
|
||||
await expect(login()).resolves.toMatchObject({ flow: 'oauth' });
|
||||
});
|
||||
|
||||
it('keeps same-host login independent of webmail metadata', async () => {
|
||||
bundle.token_endpoint = `${SERVER}/auth/token`;
|
||||
await expect(login()).resolves.toMatchObject({ flow: 'oauth' });
|
||||
expect(mockSecureFetch.mock.calls.filter(([url]) => !url.endsWith('/pair/redeem'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
const mismatches: [string, () => void][] = [
|
||||
['substituted token endpoint', () => { bundle.token_endpoint = 'https://attacker.test/token'; }],
|
||||
['lookalike token hostname', () => { bundle.token_endpoint = 'https://id.example.net.attacker.test/token'; }],
|
||||
['different token path', () => { bundle.token_endpoint = `${ISSUER}/other`; }],
|
||||
['different token query', () => { bundle.token_endpoint = `${TOKEN_ENDPOINT}?other=1`; }],
|
||||
['token URL with credentials', () => { bundle.token_endpoint = metadata.token_endpoint = 'https://user@id.example.net/token'; }],
|
||||
['token URL with a fragment', () => { bundle.token_endpoint = metadata.token_endpoint = `${TOKEN_ENDPOINT}#other`; }],
|
||||
['cleartext token endpoint', () => { bundle.token_endpoint = metadata.token_endpoint = 'http://id.example.net/token'; }],
|
||||
['different client', () => { bundle.client_id = 'different-client'; }],
|
||||
['different JMAP server', () => { bundle.server_url = 'https://other.example.com'; }],
|
||||
['disabled OAuth', () => { config.oauthEnabled = false; }],
|
||||
['non-boolean OAuth flag', () => { config.oauthEnabled = 'true'; }],
|
||||
['missing issuer', () => { delete config.oauthIssuerUrl; }],
|
||||
['cleartext issuer', () => { config.oauthIssuerUrl = metadata.issuer = 'http://id.example.net'; }],
|
||||
['issuer mismatch', () => { metadata.issuer = 'https://other-id.example.net'; }],
|
||||
['missing token endpoint', () => { delete metadata.token_endpoint; }],
|
||||
];
|
||||
it.each(mismatches)('rejects %s', async (_name, mutate) => {
|
||||
mutate();
|
||||
await expect(login()).rejects.toThrow('token endpoint is not trusted');
|
||||
});
|
||||
|
||||
it.each(['/api/config', '/api/auth/oauth/metadata'])('fails closed when %s cannot be fetched', async (path) => {
|
||||
const normal = mockSecureFetch.getMockImplementation()!;
|
||||
mockSecureFetch.mockImplementation((url, options) => {
|
||||
if (url.endsWith(path)) throw new Error('Network unavailable');
|
||||
return normal(url, options);
|
||||
});
|
||||
await expect(login()).rejects.toThrow('token endpoint is not trusted');
|
||||
});
|
||||
|
||||
const badResponses: [string, Partial<Response>][] = [
|
||||
['HTTP error', { ok: false, status: 503 }],
|
||||
['cross-origin redirect', { url: 'https://attacker.test/api/config', redirected: true }],
|
||||
['same-origin redirect', { url: `${WEBMAIL}/different`, redirected: true }],
|
||||
['redirect back to the requested URL', { url: `${WEBMAIL}/api/config`, redirected: true }],
|
||||
['missing final URL (native client-certificate bridge)', { url: '' }],
|
||||
['invalid JSON', { json: async () => { throw new SyntaxError('Invalid JSON'); } }],
|
||||
];
|
||||
it.each(badResponses)('fails closed for metadata with %s', async (_name, overrides) => {
|
||||
const normal = mockSecureFetch.getMockImplementation()!;
|
||||
mockSecureFetch.mockImplementation(async (url, options) => {
|
||||
const result = await normal(url, options);
|
||||
return url.endsWith('/pair/redeem') ? result : { ...result, ...overrides };
|
||||
});
|
||||
await expect(login()).rejects.toThrow('token endpoint is not trusted');
|
||||
});
|
||||
|
||||
it('aborts metadata discovery after its deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
const normal = mockSecureFetch.getMockImplementation()!;
|
||||
mockSecureFetch.mockImplementation((url, options) => {
|
||||
if (!url.endsWith('/api/config')) return normal(url, options);
|
||||
return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('Aborted'))));
|
||||
});
|
||||
const result = expect(login()).rejects.toThrow('token endpoint is not trusted');
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
await result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects a mismatched callback state before fetching metadata', async () => {
|
||||
stateMismatch = true;
|
||||
await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch');
|
||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves password handoff unchanged', async () => {
|
||||
bundle = { flow: 'password', server_url: SERVER, username: 'user@example.com', password: 'test-password' };
|
||||
await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({
|
||||
flow: 'password', serverUrl: SERVER, username: 'user@example.com', password: 'test-password',
|
||||
});
|
||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { jmapClient } from '../../api/jmap-client';
|
||||
import { secureFetch } from '../client-cert';
|
||||
import {
|
||||
carriesNoMail,
|
||||
handleNotificationAction,
|
||||
matchAccountsForPush,
|
||||
parseRelayPushData,
|
||||
pushBackgroundTask,
|
||||
@@ -185,6 +186,133 @@ describe('pushBackgroundTask notifications', () => {
|
||||
|
||||
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' }));
|
||||
});
|
||||
|
||||
it('includes preview and action labels on notification', async () => {
|
||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
|
||||
ok: true,
|
||||
json: async () => (url.endsWith('/.well-known/jmap')
|
||||
? {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||
accounts: { 'jmap-primary': {} },
|
||||
}
|
||||
: {
|
||||
methodResponses: [[
|
||||
'Email/get',
|
||||
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hello', preview: 'World snippet', from: [{ name: 'Bob', email: 'bob@example.com' }] }] },
|
||||
'0',
|
||||
]],
|
||||
}),
|
||||
}));
|
||||
|
||||
await pushBackgroundTask({
|
||||
kind: 'jmap-email-push',
|
||||
accountLabel: 'alice',
|
||||
accountId: 'jmap-primary',
|
||||
emailIds: JSON.stringify(['m1']),
|
||||
});
|
||||
|
||||
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
emailId: 'm1',
|
||||
title: 'Bob',
|
||||
body: 'Hello',
|
||||
preview: 'World snippet',
|
||||
markReadLabel: expect.any(String),
|
||||
deleteLabel: expect.any(String),
|
||||
replyLabel: expect.any(String),
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles markRead action via detached JMAP', async () => {
|
||||
const postCalls: any[] = [];
|
||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
|
||||
if (opts?.body) {
|
||||
postCalls.push(JSON.parse(opts.body));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => (url.endsWith('/.well-known/jmap')
|
||||
? {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||
accounts: { 'jmap-primary': {} },
|
||||
}
|
||||
: { methodResponses: [['Email/set', { updated: { m1: {} } }, '0']] }),
|
||||
};
|
||||
});
|
||||
|
||||
await handleNotificationAction({
|
||||
action: 'markRead',
|
||||
emailId: 'm1',
|
||||
accountId: LOCAL,
|
||||
});
|
||||
|
||||
expect(postCalls.length).toBe(1);
|
||||
expect(postCalls[0].methodCalls).toEqual([
|
||||
['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles delete action by moving email to trash', async () => {
|
||||
const postCalls: any[] = [];
|
||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
|
||||
if (opts?.body) {
|
||||
postCalls.push(JSON.parse(opts.body));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => (url.endsWith('/.well-known/jmap')
|
||||
? {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||
accounts: { 'jmap-primary': {} },
|
||||
}
|
||||
: {
|
||||
methodResponses: [
|
||||
['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
|
||||
['Email/set', { updated: { m1: {} } }, '0'],
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await handleNotificationAction({
|
||||
'bulwark.notification.action': 'delete',
|
||||
'bulwark.notification.emailId': 'm1',
|
||||
'bulwark.notification.accountId': LOCAL,
|
||||
});
|
||||
|
||||
expect(postCalls.length).toBe(2);
|
||||
expect(postCalls[1].methodCalls).toEqual([
|
||||
['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['has no trash mailbox', ['Mailbox/get', { list: [{ id: 'inbox', role: 'inbox' }] }, '0']],
|
||||
['cannot list mailboxes', ['error', { type: 'accountNotFound' }, '0']],
|
||||
])('never destroys from a notification when the account %s', async (_label, mailboxResponse) => {
|
||||
const postCalls: any[] = [];
|
||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
|
||||
if (opts?.body) postCalls.push(JSON.parse(opts.body));
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => (url.endsWith('/.well-known/jmap')
|
||||
? {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||
accounts: { 'jmap-primary': {} },
|
||||
}
|
||||
: { methodResponses: [mailboxResponse] }),
|
||||
};
|
||||
});
|
||||
|
||||
await handleNotificationAction({ action: 'delete', emailId: 'm1', accountId: LOCAL, jmapAccountId: 'shared' });
|
||||
|
||||
expect(postCalls).toHaveLength(1);
|
||||
expect(postCalls[0].methodCalls[0][0]).toBe('Mailbox/get');
|
||||
expect(postCalls[0].methodCalls[0][1].accountId).toBe('shared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushes for device sync (#34)', () => {
|
||||
|
||||
@@ -41,6 +41,14 @@ describe('viewerPages', () => {
|
||||
expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]);
|
||||
});
|
||||
|
||||
it('is a single page when opened with a single emailId from notification tap', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'q2', threadId: 'tq', emailIds: ['q2'],
|
||||
list: LIST, threading: true,
|
||||
});
|
||||
expect(pages).toEqual([LIST[1]]);
|
||||
});
|
||||
|
||||
it('is a single page for a message the list does not hold', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'm9', threadId: 't9', list: LIST, threading: true,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* What an event's place fields should do when tapped.
|
||||
*
|
||||
* Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives
|
||||
* with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in
|
||||
* the description, among dial-in numbers and help links. `findMeetingLink`
|
||||
* digs it out so the sheet can offer one "Join" button without touching the
|
||||
* event on the server (it is someone else's invitation).
|
||||
*
|
||||
* A physical address is handed to the platform's maps app instead.
|
||||
*/
|
||||
|
||||
import { isSafeExternalUrl } from './open-url';
|
||||
|
||||
/** The place fields read here, loose enough for any event shape. */
|
||||
export interface EventPlaces {
|
||||
locations?: Record<string, { name?: string | null; description?: string | null } | null> | null;
|
||||
virtualLocations?: Record<string, { uri?: string | null } | null> | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface MeetingLink {
|
||||
uri: string;
|
||||
/** Display name of the conferencing service, when recognised. */
|
||||
provider?: string;
|
||||
/** True when the link was found in the description, not a structured field. */
|
||||
derived: boolean;
|
||||
}
|
||||
|
||||
// Hosts whose URLs are joinable meetings. The path test keeps help pages,
|
||||
// "learn more" and dial-in links on the same host out of the result.
|
||||
const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [
|
||||
{ name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i },
|
||||
{ name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i },
|
||||
{ name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i },
|
||||
{ name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i },
|
||||
{ name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ },
|
||||
{ name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ },
|
||||
{ name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ },
|
||||
{ name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ },
|
||||
];
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
|
||||
const isHttpUrl = (value: string | undefined | null): value is string =>
|
||||
!!value && /^https?:\/\/\S+$/i.test(value.trim());
|
||||
|
||||
/**
|
||||
* Outlook's Safe Links and similar gateways wrap the real URL in a query
|
||||
* parameter; unwrap one level so the provider can be recognised.
|
||||
*/
|
||||
export function unwrapSafeLink(uri: string): string {
|
||||
try {
|
||||
const u = new URL(uri);
|
||||
if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) {
|
||||
const inner = u.searchParams.get('url');
|
||||
if (inner && isHttpUrl(inner)) return inner;
|
||||
}
|
||||
} catch {
|
||||
// not a parseable URL - keep as is
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** The conferencing service a URL joins, or null for any other link. */
|
||||
export function meetingProviderOf(uri: string): string | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(unwrapSafeLink(uri));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = u.hostname.toLowerCase();
|
||||
const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname)));
|
||||
return provider ? provider.name : null;
|
||||
}
|
||||
|
||||
function firstMeetingUrl(text: string | undefined | null): MeetingLink | null {
|
||||
if (!text) return null;
|
||||
for (const raw of text.match(URL_RE) ?? []) {
|
||||
// Trailing punctuation belongs to the sentence, not the URL.
|
||||
const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, ''));
|
||||
const provider = meetingProviderOf(candidate);
|
||||
if (provider) return { uri: candidate, provider, derived: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link that joins the meeting: the event's own virtual location first,
|
||||
* then a URL used as the location, then a known conferencing URL found in the
|
||||
* location or the description.
|
||||
*/
|
||||
export function findMeetingLink(event: EventPlaces): MeetingLink | null {
|
||||
for (const vl of Object.values(event.virtualLocations ?? {})) {
|
||||
const uri = vl?.uri;
|
||||
if (isHttpUrl(uri)) {
|
||||
return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false };
|
||||
}
|
||||
}
|
||||
const locations = Object.values(event.locations ?? {});
|
||||
for (const loc of locations) {
|
||||
const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description);
|
||||
if (found) return found;
|
||||
}
|
||||
const inDescription = firstMeetingUrl(event.description);
|
||||
if (inDescription) return inDescription;
|
||||
// Last resort: a virtual location in another scheme the app may open (a
|
||||
// tel: dial-in, say). App schemes like msteams:, zoommtg: or sip: are left
|
||||
// out because openExternalUrl refuses them, and a Join button that does
|
||||
// nothing is worse than none.
|
||||
for (const vl of Object.values(event.virtualLocations ?? {})) {
|
||||
const uri = vl?.uri?.trim();
|
||||
if (isSafeExternalUrl(uri)) return { uri, derived: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first location's display name, if any. */
|
||||
export function primaryLocationName(event: Pick<EventPlaces, 'locations'>): string | undefined {
|
||||
const name = Object.values(event.locations ?? {})[0]?.name?.trim();
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"…
|
||||
// A location like this names the service, not a place: tapping it should join
|
||||
// the meeting, never search the maps for it. The whole label must be a service
|
||||
// name, so "Salle Teams" or "Studio Zoom" stay places.
|
||||
const MEETING_LABELS = new Set([
|
||||
'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby',
|
||||
'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne',
|
||||
]);
|
||||
|
||||
export function isMeetingLabel(location: string): boolean {
|
||||
const label = location
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^(reunion|meeting|online meeting|appel|call) /, '')
|
||||
.replace(/ (meeting|reunion|call)$/, '');
|
||||
return MEETING_LABELS.has(label);
|
||||
}
|
||||
|
||||
export type LocationAction =
|
||||
| { kind: 'url'; uri: string }
|
||||
| { kind: 'maps'; query: string };
|
||||
|
||||
/**
|
||||
* What tapping the location does: open it when it is a URL, join the meeting
|
||||
* when it only names the service, search the maps otherwise.
|
||||
*/
|
||||
export function locationAction(location: string, meeting: MeetingLink | null): LocationAction {
|
||||
const value = location.trim();
|
||||
if (isHttpUrl(value)) return { kind: 'url', uri: value };
|
||||
if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri };
|
||||
return { kind: 'maps', query: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* A maps URL for a free-text address. Android's `geo:` intent opens Google
|
||||
* Maps (or the user's chosen maps app) straight on the search; elsewhere the
|
||||
* Google Maps universal link opens the app when installed, the site otherwise.
|
||||
*/
|
||||
export function mapsUrl(query: string, platform: string): string {
|
||||
const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim());
|
||||
if (platform === 'android') return `geo:0,0?q=${q}`;
|
||||
return `https://www.google.com/maps/search/?api=1&query=${q}`;
|
||||
}
|
||||
+61
-6
@@ -104,8 +104,8 @@ function randomState(): string {
|
||||
}
|
||||
|
||||
// Anything from the redirect fragment is attacker-influenced: another app can
|
||||
// register the same custom scheme. Only accept https endpoints, and only a
|
||||
// token endpoint that lives on the mail server's own host (or the webmail's).
|
||||
// register the same custom scheme. External token endpoints need verification
|
||||
// through the selected webmail, rather than trusting the redirect alone.
|
||||
function isHttpsUrl(value: string | null): value is string {
|
||||
return !!value && /^https:\/\/[^/?#\s]+/i.test(value);
|
||||
}
|
||||
@@ -133,6 +133,60 @@ export function isAcceptableTokenEndpoint(
|
||||
return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`));
|
||||
}
|
||||
|
||||
// External IdPs must be authorized by the selected webmail over TLS, never
|
||||
// by callback fields or a shared DNS suffix. Bind all returned identifiers.
|
||||
async function isTrustedHandoffEndpoint(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
webmailUrl: string,
|
||||
clientId: string,
|
||||
): Promise<boolean> {
|
||||
if (isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) return true;
|
||||
function httpsUrl(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && !url.username && !url.password && !url.hash;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!httpsUrl(webmailUrl) || !httpsUrl(tokenEndpoint) || !httpsUrl(serverUrl)) return false;
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
try {
|
||||
const load = async (path: string) => {
|
||||
const url = `${base}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
// The native client-certificate bridge omits the final URL. Without
|
||||
// transport evidence of its destination, external-IdP trust must fail.
|
||||
if (!response.ok || response.url !== url || response.redirected) {
|
||||
throw new Error('Untrusted metadata response');
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
const config = await load('/api/config');
|
||||
if (
|
||||
config.oauthEnabled !== true || config.oauthClientId !== clientId ||
|
||||
config.jmapServerUrl !== serverUrl || !httpsUrl(config.oauthIssuerUrl)
|
||||
) return false;
|
||||
const metadata = await load('/api/auth/oauth/metadata');
|
||||
return metadata.issuer === config.oauthIssuerUrl && metadata.token_endpoint === tokenEndpoint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHandoffUrl(webmailUrl: string, state: string): string {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
const params = new URLSearchParams({
|
||||
@@ -206,7 +260,7 @@ export async function runWebmailHandoff(
|
||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||
throw new HandoffError('Sign-in response missing OAuth tokens');
|
||||
}
|
||||
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) {
|
||||
if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) {
|
||||
throw new HandoffError('Sign-in response token endpoint is not trusted');
|
||||
}
|
||||
const refreshToken = params.get('refresh_token') ?? undefined;
|
||||
@@ -455,9 +509,10 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
|
||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||
throw new PairingError('bad_response', 'Pairing response missing token material', { host });
|
||||
}
|
||||
// The mail server's own host, or the webmail's token proxy
|
||||
// (`<webmail>/api/auth/pair/token`); nothing else gets the refresh token.
|
||||
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, base)) {
|
||||
// The mail server's own host, the webmail's token proxy
|
||||
// (`<webmail>/api/auth/pair/token`), or an external identity provider the
|
||||
// webmail itself names; nothing else gets the refresh token.
|
||||
if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, base, clientId))) {
|
||||
throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host });
|
||||
}
|
||||
const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getFaviconDomain,
|
||||
getFaviconUrl,
|
||||
} from './avatar-utils';
|
||||
import { singleLine } from './single-line';
|
||||
import {
|
||||
lastNotifiedKey,
|
||||
migrateLegacyPushKeys,
|
||||
@@ -87,6 +88,7 @@ interface ShowNotificationOptions {
|
||||
notificationId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
preview?: string;
|
||||
initials: string;
|
||||
bgColorHex: string;
|
||||
iconUrl?: string;
|
||||
@@ -101,6 +103,9 @@ interface ShowNotificationOptions {
|
||||
// several deliveries under a "+N more" summary instead of stacking them.
|
||||
groupKey: string;
|
||||
groupTitle: string;
|
||||
markReadLabel?: string;
|
||||
deleteLabel?: string;
|
||||
replyLabel?: string;
|
||||
}
|
||||
|
||||
interface BulwarkFcmNative {
|
||||
@@ -505,10 +510,16 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
|
||||
const faviconDomain = favicons ? getFaviconDomain(address) : null;
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const preview = email.preview ? singleLine(email.preview) : undefined;
|
||||
const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read');
|
||||
const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete');
|
||||
const replyLabel = translate(locale, 'email_viewer.reply', 'Reply');
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${email.id}`,
|
||||
title,
|
||||
body,
|
||||
preview,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
@@ -519,6 +530,9 @@ async function processAccountForPush(accountId: string, payload: RelayPushData):
|
||||
jmapAccountId: emailAccountId,
|
||||
groupKey,
|
||||
groupTitle,
|
||||
markReadLabel,
|
||||
deleteLabel,
|
||||
replyLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,3 +553,94 @@ function hslToHex(hsl: string): string {
|
||||
};
|
||||
return `#${component(0)}${component(8)}${component(4)}`;
|
||||
}
|
||||
|
||||
export interface NotificationActionPayload {
|
||||
action: 'markRead' | 'delete';
|
||||
emailId: string;
|
||||
accountId: string;
|
||||
jmapAccountId?: string;
|
||||
}
|
||||
|
||||
export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined;
|
||||
const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined;
|
||||
const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined;
|
||||
const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined;
|
||||
|
||||
if ((action === 'markRead' || action === 'delete') && emailId && accountId) {
|
||||
return { action, emailId, accountId, jmapAccountId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleNotificationAction(data: unknown): Promise<void> {
|
||||
const payload = parseNotificationActionPayload(data);
|
||||
if (!payload) {
|
||||
console.warn('[push] invalid notification action payload', data);
|
||||
return;
|
||||
}
|
||||
const { action, emailId, accountId, jmapAccountId } = payload;
|
||||
console.log(`[push] executing notification action "${action}" for email ${emailId}`);
|
||||
|
||||
try {
|
||||
const session = await openDetachedSession(accountId);
|
||||
if (!session) {
|
||||
console.warn('[push] failed to open detached session for account', accountId);
|
||||
return;
|
||||
}
|
||||
const emailAccountId = jmapAccountId ?? session.jmapAccountId;
|
||||
|
||||
if (action === 'markRead') {
|
||||
const res = await jmapPost(session, [
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId: emailAccountId,
|
||||
update: {
|
||||
[emailId]: { 'keywords/$seen': true },
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
assertEmailUpdated(res, emailId);
|
||||
} else if (action === 'delete') {
|
||||
const mbRes = await jmapPost(session, [
|
||||
['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'],
|
||||
]);
|
||||
const [mbName, mbBody] = mbRes[0] ?? [];
|
||||
const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : [];
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
// Never destroy from a notification: without a Trash (a shared
|
||||
// account, or a Mailbox/get that failed) the message stays put.
|
||||
if (!trash) {
|
||||
console.warn('[push] no trash mailbox for notification delete', emailAccountId);
|
||||
return;
|
||||
}
|
||||
const res = await jmapPost(session, [
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId: emailAccountId,
|
||||
update: {
|
||||
[emailId]: { mailboxIds: { [trash.id]: true } },
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
assertEmailUpdated(res, emailId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[push] notification action failed', action, emailId, err);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEmailUpdated(responses: Array<[string, Record<string, any>, string]>, emailId: string): void {
|
||||
const [name, body] = responses[0] ?? [];
|
||||
if (name !== 'Email/set' || !body?.updated || !(emailId in body.updated)) {
|
||||
throw new Error(`Email/set did not update ${emailId}: ${JSON.stringify(body?.notUpdated?.[emailId] ?? body ?? null)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1443,6 +1443,8 @@ export interface NotificationTapPayload {
|
||||
// The JMAP account the message lives in: a group or shared mailbox's account
|
||||
// when it isn't the user's own. Absent on older notifications.
|
||||
jmapAccountId?: string;
|
||||
// Notification action tapped (e.g. 'reply' to jump straight to Compose)
|
||||
action?: 'reply';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
|
||||
Keyboard, Dimensions, Platform,
|
||||
} from 'react-native';
|
||||
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
@@ -639,6 +640,42 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Track the visible keyboard obstruction so quick reply stays visible and
|
||||
// the bottom bar hides while typing. On Android edge-to-edge, the IME inset
|
||||
// is measured from the top of the gesture bar rather than screen bottom,
|
||||
// so we derive obstruction height from Dimensions.screen.
|
||||
const [kbObstruction, setKbObstruction] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const recompute = (endY?: number, height?: number) => {
|
||||
const screenH = Dimensions.get('screen').height;
|
||||
if (typeof endY === 'number' && screenH - endY > 0) {
|
||||
setKbObstruction(screenH - endY);
|
||||
} else if (typeof height === 'number' && height > 0) {
|
||||
setKbObstruction(height);
|
||||
} else {
|
||||
setKbObstruction(0);
|
||||
}
|
||||
};
|
||||
const subs =
|
||||
Platform.OS === 'ios'
|
||||
? [
|
||||
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
|
||||
recompute(e.endCoordinates?.screenY, e.endCoordinates?.height);
|
||||
}),
|
||||
Keyboard.addListener('keyboardWillHide', () => setKbObstruction(0)),
|
||||
]
|
||||
: [
|
||||
Keyboard.addListener('keyboardDidShow', (e) => {
|
||||
recompute(e.endCoordinates?.screenY, e.endCoordinates?.height);
|
||||
}),
|
||||
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
|
||||
];
|
||||
return () => {
|
||||
for (const s of subs) s.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drop optional toolbar buttons on narrow screens.
|
||||
const showMarkUnread = windowWidth >= 340;
|
||||
const showArchive = windowWidth >= 400 && canArchive;
|
||||
@@ -738,7 +775,7 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={2}
|
||||
removeClippedSubviews
|
||||
scrollEnabled={!pagerLocked}
|
||||
scrollEnabled={!pagerLocked && kbObstruction === 0}
|
||||
onScrollBeginDrag={releaseNeighbours}
|
||||
onMomentumScrollEnd={onMomentumEnd}
|
||||
renderItem={({ item, index }) => (
|
||||
@@ -769,6 +806,7 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
scheduleMarkRead={scheduleMarkRead}
|
||||
styles={styles}
|
||||
bottomBarHeight={bottomBarHeight}
|
||||
kbObstruction={item.id === activeEmailId ? kbObstruction : 0}
|
||||
onToggleStar={toggleStarFor}
|
||||
onAddressPress={setAddressSheet}
|
||||
onEmailPatched={onEmailPatched}
|
||||
@@ -780,33 +818,35 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Bottom action bar */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||
<BottomBarButton
|
||||
icon={<ChevronLeft size={20} color={c.textMuted} />}
|
||||
label={t('email_viewer.previous', 'Prev')}
|
||||
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
{bottomActions.map((id) => {
|
||||
const def = quickActionRegistry[id];
|
||||
return (
|
||||
<BottomBarButton
|
||||
key={id}
|
||||
icon={def.icon(20, c.textSecondary)}
|
||||
label={def.label}
|
||||
onPress={def.available ? def.onPress : undefined}
|
||||
disabled={!def.available}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||
label={t('email_viewer.next', 'Next')}
|
||||
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
|
||||
disabled={!nextEmail}
|
||||
/>
|
||||
</View>
|
||||
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
|
||||
{kbObstruction === 0 && (
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||
<BottomBarButton
|
||||
icon={<ChevronLeft size={20} color={c.textMuted} />}
|
||||
label={t('email_viewer.previous', 'Prev')}
|
||||
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
{bottomActions.map((id) => {
|
||||
const def = quickActionRegistry[id];
|
||||
return (
|
||||
<BottomBarButton
|
||||
key={id}
|
||||
icon={def.icon(20, c.textSecondary)}
|
||||
label={def.label}
|
||||
onPress={def.available ? def.onPress : undefined}
|
||||
disabled={!def.available}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||
label={t('email_viewer.next', 'Next')}
|
||||
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
|
||||
disabled={!nextEmail}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -917,6 +957,7 @@ interface EmailPaneProps {
|
||||
scheduleMarkRead: (email: Email) => () => void;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
bottomBarHeight: number;
|
||||
kbObstruction: number;
|
||||
onToggleStar: (email: Email) => void;
|
||||
onAddressPress: (address: EmailAddress) => void;
|
||||
onEmailPatched: (email: Email) => void;
|
||||
@@ -933,7 +974,7 @@ interface EmailPaneProps {
|
||||
function EmailPane({
|
||||
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
|
||||
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
|
||||
ensureThread, scheduleMarkRead, styles, bottomBarHeight, onToggleStar, onAddressPress,
|
||||
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
|
||||
onEmailPatched, onReply, onSwipe, onZoomChange,
|
||||
}: EmailPaneProps) {
|
||||
const c = useColors();
|
||||
@@ -1028,13 +1069,42 @@ function EmailPane({
|
||||
onZoomChange(z);
|
||||
};
|
||||
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const isInputFocused = React.useRef(false);
|
||||
|
||||
const bottomPadding = kbObstruction > 0
|
||||
? kbObstruction + spacing.md
|
||||
: bottomBarHeight + spacing.lg;
|
||||
|
||||
const handleQuickReplyFocus = () => {
|
||||
isInputFocused.current = true;
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleQuickReplyBlur = () => {
|
||||
isInputFocused.current = false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (kbObstruction > 0 && isInputFocused.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: true });
|
||||
}, 50);
|
||||
}
|
||||
}, [kbObstruction]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
// At least a page tall: a single message's body takes the space below
|
||||
// its header, and the quick reply stays at the bottom however the body
|
||||
// grows or shrinks once it has measured itself.
|
||||
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomBarHeight + spacing.lg }]}
|
||||
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomPadding }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
scrollEnabled={!pinching}
|
||||
>
|
||||
{/* Subject block */}
|
||||
@@ -1112,6 +1182,8 @@ function EmailPane({
|
||||
jmapAccountId={jmapAccountId}
|
||||
onMoreOptions={() => onReply('reply', newest)}
|
||||
onSent={onEmailPatched}
|
||||
onFocus={handleQuickReplyFocus}
|
||||
onBlur={handleQuickReplyBlur}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user