diff --git a/App.tsx b/App.tsx
index 5e152ebf..a275df46 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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 } : {}),
});
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8106099a..5485a398 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -76,6 +76,7 @@
+
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
index 6922adbe..105c4442 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
@@ -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,25 @@ 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)
- .setContentText(body)
- .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setContentText(subject ?: preview ?: 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 +168,69 @@ 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).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 +246,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 +265,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 +367,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
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt
new file mode 100644
index 00000000..ae210a4e
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt
@@ -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"
+ }
+}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt
index baed3c73..1ebebf61 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt
@@ -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"
}
diff --git a/index.ts b/index.ts
index 7db96696..965b95a4 100644
--- a/index.ts
+++ b/index.ts
@@ -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.
diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts
index b8298866..dadfd7b8 100644
--- a/src/lib/__tests__/push-background-task.test.ts
+++ b/src/lib/__tests__/push-background-task.test.ts
@@ -16,6 +16,7 @@ import { jmapClient } from '../../api/jmap-client';
import { secureFetch } from '../client-cert';
import {
carriesNoMail,
+ handleNotificationAction,
matchAccountsForPush,
parseRelayPushData,
pushBackgroundTask,
@@ -185,6 +186,107 @@ 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).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).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).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'],
+ ]);
+ });
});
describe('pushes for device sync (#34)', () => {
diff --git a/src/lib/__tests__/viewer-pages.test.ts b/src/lib/__tests__/viewer-pages.test.ts
index bad52442..1120ad41 100644
--- a/src/lib/__tests__/viewer-pages.test.ts
+++ b/src/lib/__tests__/viewer-pages.test.ts
@@ -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,
diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts
index 1f513bc9..56aa87ec 100644
--- a/src/lib/push-background-task.ts
+++ b/src/lib/push-background-task.ts
@@ -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,93 @@ 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;
+ 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 {
+ 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') {
+ await jmapPost(session, [
+ [
+ 'Email/set',
+ {
+ accountId: emailAccountId,
+ update: {
+ [emailId]: { 'keywords/$seen': true },
+ },
+ },
+ '0',
+ ],
+ ]);
+ } else if (action === 'delete') {
+ const mbRes = await jmapPost(session, [
+ ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'],
+ ]);
+ const [, mbBody] = mbRes[0] ?? [];
+ const mailboxes = ((mbBody?.list as Mailbox[]) ?? []);
+ const trash = mailboxes.find((m) => m.role === 'trash');
+
+ if (trash) {
+ await jmapPost(session, [
+ [
+ 'Email/set',
+ {
+ accountId: emailAccountId,
+ update: {
+ [emailId]: { mailboxIds: { [trash.id]: true } },
+ },
+ },
+ '0',
+ ],
+ ]);
+ } else {
+ await jmapPost(session, [
+ [
+ 'Email/set',
+ {
+ accountId: emailAccountId,
+ destroy: [emailId],
+ },
+ '0',
+ ],
+ ]);
+ }
+ }
+ } catch (err) {
+ console.warn('[push] notification action failed', action, emailId, err);
+ }
+}
diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts
index 97c48971..5ea47bd0 100644
--- a/src/lib/push-notifications.ts
+++ b/src/lib/push-notifications.ts
@@ -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';
}
/**
diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx
index aad8f3f9..03aa7996 100644
--- a/src/screens/EmailThreadScreen.tsx
+++ b/src/screens/EmailThreadScreen.tsx
@@ -735,12 +735,17 @@ function EmailViewer({ route, navigation }: Props) {
initialScrollIndex={initialIndexRef.current}
getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })}
windowSize={3}
- initialNumToRender={1}
+ initialNumToRender={Math.max(initialIndexRef.current + 1, 1)}
maxToRenderPerBatch={2}
removeClippedSubviews
scrollEnabled={!pagerLocked}
onScrollBeginDrag={releaseNeighbours}
onMomentumScrollEnd={onMomentumEnd}
+ onScrollToIndexFailed={(info) => {
+ setTimeout(() => {
+ listRef.current?.scrollToOffset({ offset: info.index * windowWidth, animated: false });
+ }, 50);
+ }}
renderItem={({ item, index }) => (