diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4fb70ab1a89..c88798ebd33 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -92,6 +92,10 @@
android:enabled="true"
android:exported="true" >
+
> notificationMessages = new ConcurrentHashMap<>();
-
+
// Constants
public static final String KEY_REPLY = "KEY_REPLY";
public static final String NOTIFICATION_ID = "NOTIFICATION_ID";
private static final String CHANNEL_ID = "rocketchatrn_channel_01";
private static final String CHANNEL_NAME = "All";
-
+
// Instance fields
private final Context mContext;
private Bundle mBundle;
private final NotificationManager notificationManager;
-
+
public CustomPushNotification(Context context, Bundle bundle) {
this.mContext = context;
this.mBundle = bundle;
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
-
+
// Ensure notification channel exists
createNotificationChannel();
}
@@ -84,7 +85,7 @@ public static void setReactContext(ReactApplicationContext context) {
public static void clearMessages(int notId) {
notificationMessages.remove(Integer.toString(notId));
}
-
+
/**
* Check if React Native is initialized
*/
@@ -94,35 +95,37 @@ private boolean isReactInitialized() {
public void onReceived() {
String notId = mBundle.getString("notId");
-
+
if (notId == null || notId.isEmpty()) {
Log.w(TAG, "Missing notification ID, ignoring notification");
return;
}
-
+
try {
Integer.parseInt(notId);
} catch (NumberFormatException e) {
Log.w(TAG, "Invalid notification ID format: " + notId);
return;
}
-
- // Check if React is ready - needed for MMKV access (avatars, encryption, message-id-only)
+
+ // Check if React is ready - needed for MMKV access (avatars, encryption,
+ // message-id-only)
if (!isReactInitialized()) {
Log.w(TAG, "React not initialized yet, waiting before processing notification...");
-
+
// Wait for React to initialize with timeout
new Thread(() -> {
int attempts = 0;
int maxAttempts = 50; // 5 seconds total (50 * 100ms)
-
+
while (!isReactInitialized() && attempts < maxAttempts) {
try {
Thread.sleep(100); // Wait 100ms
attempts++;
-
+
if (attempts % 10 == 0 && ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)");
+ Log.d(TAG,
+ "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)");
}
} catch (InterruptedException e) {
Log.e(TAG, "Wait interrupted", e);
@@ -130,7 +133,7 @@ public void onReceived() {
return;
}
}
-
+
if (isReactInitialized()) {
Log.i(TAG, "React initialized after " + (attempts * 100) + "ms, proceeding with notification");
try {
@@ -139,7 +142,8 @@ public void onReceived() {
Log.e(TAG, "Failed to process notification after React initialization", e);
}
} else {
- Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100) + "ms, processing without MMKV");
+ Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100)
+ + "ms, processing without MMKV");
try {
handleNotification();
} catch (Exception e) {
@@ -147,25 +151,26 @@ public void onReceived() {
}
}
}).start();
-
+
return; // Exit early, notification will be processed in the thread
}
-
+
if (ENABLE_VERBOSE_LOGS) {
Log.d(TAG, "React already initialized, proceeding with notification");
}
-
+
try {
handleNotification();
} catch (Exception e) {
Log.e(TAG, "Failed to process notification on main thread", e);
}
}
-
+
private void handleNotification() {
Ejson receivedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class);
- if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) {
+ if (receivedEjson != null && receivedEjson.notificationType != null
+ && receivedEjson.notificationType.equals("message-id-only")) {
Log.d(TAG, "Detected message-id-only notification, will fetch full content from server");
loadNotificationAndProcess(receivedEjson);
return; // Exit early, notification will be processed in callback
@@ -174,43 +179,52 @@ private void handleNotification() {
// For non-message-id-only notifications, process immediately
processNotification();
}
-
+
private void loadNotificationAndProcess(Ejson ejson) {
notificationLoad(ejson, new Callback() {
@Override
public void call(@Nullable Bundle bundle) {
if (bundle != null) {
Log.d(TAG, "Successfully loaded notification content from server, updating notification props");
-
+
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[BEFORE update] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
- Log.d(TAG, "[BEFORE update] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]"));
- Log.d(TAG, "[BEFORE update] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0));
+ Log.d(TAG, "[BEFORE update] bundle.notificationLoaded="
+ + bundle.getBoolean("notificationLoaded", false));
+ Log.d(TAG, "[BEFORE update] bundle.title="
+ + (bundle.getString("title") != null ? "[present]" : "[null]"));
+ Log.d(TAG, "[BEFORE update] bundle.message length="
+ + (bundle.getString("message") != null ? bundle.getString("message").length() : 0));
}
-
- synchronized(CustomPushNotification.this) {
+
+ synchronized (CustomPushNotification.this) {
mBundle = bundle;
}
} else {
- Log.w(TAG, "Failed to load notification content from server, will display placeholder notification");
+ Log.w(TAG,
+ "Failed to load notification content from server, will display placeholder notification");
}
-
+
processNotification();
}
});
}
-
+
private void processNotification() {
Ejson loadedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class);
String notId = mBundle.getString("notId", "1");
if (ENABLE_VERBOSE_LOGS) {
Log.d(TAG, "[processNotification] notId=" + notId);
- Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + mBundle.getBoolean("notificationLoaded", false));
- Log.d(TAG, "[processNotification] bundle.title=" + (mBundle.getString("title") != null ? "[present]" : "[null]"));
- Log.d(TAG, "[processNotification] bundle.message length=" + (mBundle.getString("message") != null ? mBundle.getString("message").length() : 0));
- Log.d(TAG, "[processNotification] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null"));
- Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null"));
+ Log.d(TAG, "[processNotification] bundle.notificationLoaded="
+ + mBundle.getBoolean("notificationLoaded", false));
+ Log.d(TAG, "[processNotification] bundle.title="
+ + (mBundle.getString("title") != null ? "[present]" : "[null]"));
+ Log.d(TAG, "[processNotification] bundle.message length="
+ + (mBundle.getString("message") != null ? mBundle.getString("message").length() : 0));
+ Log.d(TAG, "[processNotification] loadedEjson.notificationType="
+ + (loadedEjson != null ? loadedEjson.notificationType : "null"));
+ Log.d(TAG, "[processNotification] loadedEjson.sender="
+ + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null"));
}
// Handle E2E encrypted notifications
@@ -238,7 +252,7 @@ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) {
if (reactApplicationContext != null) {
// Fast path: decrypt immediately
String decrypted = Encryption.shared.decryptMessage(ejson, reactApplicationContext);
-
+
if (decrypted != null) {
bundle.putString("message", decrypted);
mBundle = bundle;
@@ -249,35 +263,35 @@ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) {
}
return;
}
-
+
// Slow path: wait for React context asynchronously
Log.i(TAG, "Waiting for React context to decrypt E2E notification");
-
+
E2ENotificationProcessor processor = new E2ENotificationProcessor(
- // Context provider
- () -> reactApplicationContext,
-
- // Callback
- new E2ENotificationProcessor.NotificationCallback() {
- @Override
- public void onDecryptionComplete(Bundle decryptedBundle, Ejson decryptedEjson, String notificationId) {
- mBundle = decryptedBundle;
- Ejson finalEjson = safeFromJson(decryptedBundle.getString("ejson", "{}"), Ejson.class);
- showNotification(decryptedBundle, finalEjson, notificationId);
- }
-
- @Override
- public void onDecryptionFailed(Bundle originalBundle, Ejson originalEjson, String notificationId) {
- Log.w(TAG, "E2E decryption failed for notification");
- }
-
- @Override
- public void onTimeout(Bundle originalBundle, Ejson originalEjson, String notificationId) {
- Log.w(TAG, "Timeout waiting for React context for E2E notification");
- }
- }
- );
-
+ // Context provider
+ () -> reactApplicationContext,
+
+ // Callback
+ new E2ENotificationProcessor.NotificationCallback() {
+ @Override
+ public void onDecryptionComplete(Bundle decryptedBundle, Ejson decryptedEjson,
+ String notificationId) {
+ mBundle = decryptedBundle;
+ Ejson finalEjson = safeFromJson(decryptedBundle.getString("ejson", "{}"), Ejson.class);
+ showNotification(decryptedBundle, finalEjson, notificationId);
+ }
+
+ @Override
+ public void onDecryptionFailed(Bundle originalBundle, Ejson originalEjson, String notificationId) {
+ Log.w(TAG, "E2E decryption failed for notification");
+ }
+
+ @Override
+ public void onTimeout(Bundle originalBundle, Ejson originalEjson, String notificationId) {
+ Log.w(TAG, "Timeout waiting for React context for E2E notification");
+ }
+ });
+
processor.processAsync(bundle, ejson, notId);
}
@@ -298,7 +312,7 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
bundle.putLong("time", new Date().getTime());
bundle.putString("username", hasSender ? ejson.sender.username : title);
bundle.putString("senderId", hasSender ? ejson.sender._id : "1");
-
+
String avatarUri = ejson != null ? ejson.getAvatarUri() : null;
if (ENABLE_VERBOSE_LOGS) {
Log.d(TAG, "[showNotification] avatarUri=" + (avatarUri != null ? "[present]" : "[null]"));
@@ -312,11 +326,15 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
} else {
// Show regular notification
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
+ Log.d(TAG,
+ "[Before add to notificationMessages] notId=" + notId + ", bundle.message length="
+ + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)
+ + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
}
notificationMessages.get(notId).add(bundle);
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size());
+ Log.d(TAG, "[After add] notificationMessages[" + notId + "].size="
+ + notificationMessages.get(notId).size());
}
postNotification(Integer.parseInt(notId));
}
@@ -328,7 +346,7 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
*/
private void handleVideoConfNotification(Bundle bundle, Ejson ejson) {
VideoConfNotification videoConf = new VideoConfNotification(mContext);
-
+
Integer status = ejson.status;
String rid = ejson.rid;
// Video conf uses 'caller' field, regular messages use 'sender'
@@ -338,9 +356,9 @@ private void handleVideoConfNotification(Bundle bundle, Ejson ejson) {
} else if (ejson.sender != null && ejson.sender._id != null) {
callerId = ejson.sender._id;
}
-
+
Log.d(TAG, "Video conf notification - status: " + status + ", rid: " + rid);
-
+
if (status == null || status == 0) {
// Incoming call - show notification
videoConf.showIncomingCall(bundle, ejson);
@@ -358,14 +376,13 @@ private void postNotification(int notificationId) {
notificationManager.notify(notificationId, notification.build());
}
}
-
+
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
- CHANNEL_ID,
- CHANNEL_NAME,
- NotificationManager.IMPORTANCE_HIGH
- );
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH);
if (notificationManager != null) {
notificationManager.createNotificationChannel(channel);
}
@@ -390,12 +407,14 @@ private Notification.Builder buildNotification(int notificationId) {
Intent intent = new Intent(mContext, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtras(mBundle);
-
+
PendingIntent pendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
} else {
- pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
}
Notification.Builder notification;
@@ -417,11 +436,14 @@ private Notification.Builder buildNotification(int notificationId) {
notificationIcons(notification, mBundle);
notificationDismiss(notification, notificationId);
- // if notificationType is null (RC < 3.5) or notificationType is different of message-id-only or notification was loaded successfully
- if (ejson == null || ejson.notificationType == null || !ejson.notificationType.equals("message-id-only") || notificationLoaded) {
+ // if notificationType is null (RC < 3.5) or notificationType is different of
+ // message-id-only or notification was loaded successfully
+ if (ejson == null || ejson.notificationType == null || !ejson.notificationType.equals("message-id-only")
+ || notificationLoaded) {
Log.i(TAG, "[buildNotification] ✅ Rendering FULL notification style");
notificationStyle(notification, notificationId, mBundle);
notificationReply(notification, notificationId, mBundle);
+ notificationMarkAsRead(notification, notificationId, mBundle);
} else {
Log.w(TAG, "[buildNotification] ⚠️ Rendering FALLBACK notification");
// Cancel previous fallback notifications from same server
@@ -430,7 +452,7 @@ private Notification.Builder buildNotification(int notificationId) {
return notification;
}
-
+
private void cancelPreviousFallbackNotifications(Ejson ejson) {
for (Map.Entry> bundleList : notificationMessages.entrySet()) {
Iterator iterator = bundleList.getValue().iterator();
@@ -461,7 +483,7 @@ private Bitmap getAvatar(String uri) {
}
return largeIcon();
}
-
+
if (ENABLE_VERBOSE_LOGS) {
String sanitizedUri = uri;
int queryStart = uri.indexOf("?");
@@ -470,7 +492,7 @@ private Bitmap getAvatar(String uri) {
}
Log.d(TAG, "Fetching avatar from: " + sanitizedUri);
}
-
+
try {
// Use a 3-second timeout to avoid blocking the FCM service for too long
// FCM has a 10-second limit, so we need to fail fast and use fallback icon
@@ -480,7 +502,7 @@ private Bitmap getAvatar(String uri) {
.load(uri)
.submit(100, 100)
.get(3, TimeUnit.SECONDS);
-
+
return avatar != null ? avatar : largeIcon();
} catch (final ExecutionException | InterruptedException | TimeoutException e) {
Log.e(TAG, "Failed to fetch avatar: " + e.getMessage(), e);
@@ -533,7 +555,8 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun
List bundles = notificationMessages.get(Integer.toString(notId));
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null"));
+ Log.d(TAG,
+ "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null"));
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
@@ -612,9 +635,11 @@ private void notificationReply(Notification.Builder notification, int notificati
PendingIntent replyPendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
+ replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
- replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
}
RemoteInput remoteInput = new RemoteInput.Builder(KEY_REPLY)
@@ -631,11 +656,44 @@ private void notificationReply(Notification.Builder notification, int notificati
.addAction(replyAction);
}
+ private void notificationMarkAsRead(Notification.Builder notification, int notificationId, Bundle bundle) {
+ String notId = bundle.getString("notId", "1");
+ String ejson = bundle.getString("ejson", "{}");
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || notId.equals("1") || ejson.equals("{}")) {
+ return;
+ }
+ String label = "Mark as read";
+
+ final Resources res = mContext.getResources();
+ String packageName = mContext.getPackageName();
+ int smallIconResId = res.getIdentifier("ic_notification", "drawable", packageName);
+
+ Intent markAsReadIntent = new Intent(mContext, MarkAsReadBroadcast.class);
+ markAsReadIntent.setAction(MarkAsReadBroadcast.KEY_MARK_AS_READ);
+ markAsReadIntent.putExtra("pushNotification", bundle);
+
+ PendingIntent markAsReadPendingIntent;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ markAsReadPendingIntent = PendingIntent.getBroadcast(mContext, notificationId + 1000, markAsReadIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ markAsReadPendingIntent = PendingIntent.getBroadcast(mContext, notificationId + 1000, markAsReadIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ Notification.Action markAsReadAction = new Notification.Action.Builder(smallIconResId, label,
+ markAsReadPendingIntent)
+ .build();
+
+ notification.addAction(markAsReadAction);
+ }
+
private void notificationDismiss(Notification.Builder notification, int notificationId) {
Intent intent = new Intent(mContext, DismissNotification.class);
intent.putExtra(NOTIFICATION_ID, notificationId);
- PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
+ PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, intent,
+ PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
notification.setDeleteIntent(dismissPendingIntent);
}
@@ -644,7 +702,7 @@ private void notificationLoad(Ejson ejson, Callback callback) {
LoadNotification loadNotification = new LoadNotification();
loadNotification.load(ejson, callback);
}
-
+
/**
* Safely parses JSON string to object with error handling.
*/
diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java
new file mode 100644
index 00000000000..5bf2f9eb24b
--- /dev/null
+++ b/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java
@@ -0,0 +1,99 @@
+package chat.rocket.reactnative.notification;
+
+import android.app.NotificationManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.gson.Gson;
+import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
+
+import java.io.IOException;
+
+import okhttp3.Call;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class MarkAsReadBroadcast extends BroadcastReceiver {
+ private static final String TAG = "RocketChat.MarkAsRead";
+ public static final String KEY_MARK_AS_READ = "KEY_MARK_AS_READ";
+ private static final OkHttpClient client = new OkHttpClient();
+ private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Keep receiver alive for async network operation
+ final PendingResult pendingResult = goAsync();
+
+ Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
+ NotificationManager notificationManager = (NotificationManager) context
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+
+ String notId = bundle.getString("notId");
+
+ Gson gson = new Gson();
+ Ejson ejson = gson.fromJson(bundle.getString("ejson", "{}"), Ejson.class);
+
+ try {
+ int id = Integer.parseInt(notId);
+ markAsRead(ejson, id, notificationManager, pendingResult);
+ } catch (NumberFormatException e) {
+ Log.e(TAG, "Invalid notification ID: " + notId, e);
+ pendingResult.finish();
+ }
+ }
+
+ protected void markAsRead(final Ejson ejson, final int notId,
+ final NotificationManager notificationManager,
+ final PendingResult pendingResult) {
+ String serverURL = ejson.serverURL();
+ String rid = ejson.rid;
+
+ if (serverURL == null || rid == null) {
+ Log.e(TAG, "Missing serverURL or rid");
+ pendingResult.finish();
+ return;
+ }
+
+ String json = String.format("{\"rid\":\"%s\"}", rid);
+
+ RequestBody body = RequestBody.create(JSON, json);
+ Request request = new Request.Builder()
+ .header("x-auth-token", ejson.token())
+ .header("x-user-id", ejson.userId())
+ .url(String.format("%s/api/v1/subscriptions.read", serverURL))
+ .post(body)
+ .build();
+
+ client.newCall(request).enqueue(new okhttp3.Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ Log.e(TAG, "Mark as read FAILED: " + e.getMessage());
+ pendingResult.finish();
+ }
+
+ @Override
+ public void onResponse(Call call, final Response response) throws IOException {
+ try {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "Mark as read SUCCESS");
+ CustomPushNotification.clearMessages(notId);
+ notificationManager.cancel(notId);
+ } else {
+ Log.e(TAG, String.format("Mark as read FAILED status %s", response.code()));
+ }
+ } finally {
+ if (response.body() != null) {
+ response.body().close();
+ }
+ pendingResult.finish();
+ }
+ }
+ });
+ }
+}
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 20adc12eb90..d40c30c0bb7 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -499,6 +499,7 @@
"Mark_as_unread_Info": "Display room as unread when there are unread messages",
"Mark_read": "Mark read",
"Mark_unread": "Mark unread",
+ "Mark_as_read": "Mark as read",
"Markdown_tools": "Markdown tools",
"Max_number_of_users_allowed_is_number": "Max number of users allowed is {{maxUsers}}",
"Max_number_of_uses": "Max number of uses",
diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts
index c5725ec6f4c..85249cc411f 100644
--- a/app/lib/notifications/push.ts
+++ b/app/lib/notifications/push.ts
@@ -88,7 +88,7 @@ const setupNotificationCategories = async (): Promise => {
}
try {
- // Message category with Reply action
+ // Message category with Reply and Mark as Read actions
await Notifications.setNotificationCategoryAsync('MESSAGE', [
{
identifier: 'REPLY_ACTION',
@@ -100,6 +100,13 @@ const setupNotificationCategories = async (): Promise => {
options: {
opensAppToForeground: false
}
+ },
+ {
+ identifier: 'MARK_AS_READ_ACTION',
+ buttonTitle: I18n.t('Mark_as_read'),
+ options: {
+ opensAppToForeground: false
+ }
}
]);
diff --git a/ios/ReplyNotification.swift b/ios/ReplyNotification.swift
index 4c3ea7c2183..d98b70675b0 100644
--- a/ios/ReplyNotification.swift
+++ b/ios/ReplyNotification.swift
@@ -16,20 +16,20 @@ import UserNotifications
class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
private static var shared: ReplyNotification?
private weak var originalDelegate: UNUserNotificationCenterDelegate?
-
+
@objc
public static func configure() {
let instance = ReplyNotification()
shared = instance
-
+
// Store the original delegate (expo-notifications) and set ourselves as the delegate
let center = UNUserNotificationCenter.current()
instance.originalDelegate = center.delegate
center.delegate = instance
}
-
+
// MARK: - UNUserNotificationCenterDelegate
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
@@ -40,7 +40,13 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
handleReplyAction(response: response, completionHandler: completionHandler)
return
}
-
+
+ // Handle MARK_AS_READ_ACTION natively
+ if response.actionIdentifier == "MARK_AS_READ_ACTION" {
+ handleMarkAsReadAction(response: response, completionHandler: completionHandler)
+ return
+ }
+
// Forward to original delegate (expo-notifications)
if let originalDelegate = originalDelegate {
originalDelegate.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler)
@@ -48,7 +54,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler()
}
}
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -61,7 +67,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler([])
}
}
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
@@ -73,17 +79,17 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
}
}
}
-
+
// MARK: - Reply Handling
-
+
private func handleReplyAction(response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
guard let textResponse = response as? UNTextInputNotificationResponse else {
completionHandler()
return
}
-
+
let userInfo = response.notification.request.content.userInfo
-
+
guard let ejsonString = userInfo["ejson"] as? String,
let ejsonData = ejsonString.data(using: .utf8),
let payload = try? JSONDecoder().decode(Payload.self, from: ejsonData),
@@ -91,11 +97,11 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler()
return
}
-
+
let message = textResponse.userText
let rocketchat = RocketChat(server: payload.host.removeTrailingSlash())
let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
-
+
rocketchat.sendMessage(rid: rid, message: message, threadIdentifier: payload.tmid) { response in
// Ensure we're on the main thread for UI operations
DispatchQueue.main.async {
@@ -103,7 +109,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
UIApplication.shared.endBackgroundTask(backgroundTask)
completionHandler()
}
-
+
guard let response = response, response.success else {
// Show failure notification
let content = UNMutableNotificationContent()
@@ -115,4 +121,26 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
}
}
}
+
+ private func handleMarkAsReadAction(response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
+ let userInfo = response.notification.request.content.userInfo
+
+ guard let ejsonString = userInfo["ejson"] as? String,
+ let ejsonData = ejsonString.data(using: .utf8),
+ let payload = try? JSONDecoder().decode(Payload.self, from: ejsonData),
+ let rid = payload.rid else {
+ completionHandler()
+ return
+ }
+
+ let rocketchat = RocketChat(server: payload.host.removeTrailingSlash())
+ let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
+
+ rocketchat.markAsRead(rid: rid) { response in
+ DispatchQueue.main.async {
+ UIApplication.shared.endBackgroundTask(backgroundTask)
+ completionHandler()
+ }
+ }
+ }
}
diff --git a/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift b/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift
new file mode 100644
index 00000000000..3e45d63e3d8
--- /dev/null
+++ b/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift
@@ -0,0 +1,34 @@
+//
+// MarkAsRead.swift
+// RocketChatRN
+//
+// Created for Mark as Read notification action
+// Copyright © 2025 Rocket.Chat. All rights reserved.
+//
+
+import Foundation
+
+struct MarkAsReadBody: Codable {
+ let rid: String
+}
+
+struct MarkAsReadResponse: Response {
+ var success: Bool
+}
+
+final class MarkAsReadRequest: Request {
+ typealias ResponseType = MarkAsReadResponse
+
+ let method: HTTPMethod = .post
+ let path = "/api/v1/subscriptions.read"
+
+ let rid: String
+
+ init(rid: String) {
+ self.rid = rid
+ }
+
+ func body() -> Data? {
+ return try? JSONEncoder().encode(MarkAsReadBody(rid: rid))
+ }
+}
diff --git a/ios/Shared/RocketChat/RocketChat.swift b/ios/Shared/RocketChat/RocketChat.swift
index 83d50139e6d..bafdf2c8cc4 100644
--- a/ios/Shared/RocketChat/RocketChat.swift
+++ b/ios/Shared/RocketChat/RocketChat.swift
@@ -11,53 +11,51 @@ import Foundation
final class RocketChat {
typealias Server = String
typealias RoomId = String
-
+
let server: Server
let api: API?
private var encryptionQueue = DispatchQueue(label: "chat.rocket.encryptionQueue")
-
+
init(server: Server) {
self.server = server
self.api = API(server: server)
}
-
- func getPushWithId(_ msgId: String, completion: @escaping((Notification?) -> Void)) {
+
+ func getPushWithId(_ msgId: String, completion: @escaping (Notification?) -> Void) {
api?.fetch(request: PushRequest(msgId: msgId), retry: Retry(retries: 4)) { response in
switch response {
case .resource(let response):
let notification = response.data.notification
completion(notification)
-
+
case .error:
completion(nil)
- break
}
}
}
-
- func sendMessage(rid: String, message: String, threadIdentifier: String?, completion: @escaping((MessageResponse?) -> Void)) {
+
+ func sendMessage(rid: String, message: String, threadIdentifier: String?, completion: @escaping (MessageResponse?) -> Void) {
let id = String.random(length: 17)
-
+
let encrypted = Database(server: server).readRoomEncrypted(for: rid)
-
+
if encrypted {
let encryption = Encryption(server: server, rid: rid)
guard let content = encryption.encryptContent(message) else {
return
}
-
+
// For backward compatibility, also set msg field
let msg = content.algorithm == "rc.v2.aes-sha2" ? "" : content.ciphertext
-
+
api?.fetch(request: SendMessageRequest(id: id, roomId: rid, text: msg, content: content, threadIdentifier: threadIdentifier, messageType: .e2e)) { response in
switch response {
case .resource(let response):
completion(response)
-
+
case .error:
completion(nil)
- break
}
}
} else {
@@ -65,19 +63,30 @@ final class RocketChat {
switch response {
case .resource(let response):
completion(response)
-
+
case .error:
completion(nil)
- break
}
}
}
}
-
+
func decryptContent(rid: String, content: EncryptedContent) -> String? {
encryptionQueue.sync {
let encryption = Encryption(server: server, rid: rid)
return encryption.decryptContent(content: content)
}
}
+
+ func markAsRead(rid: String, completion: @escaping (MarkAsReadResponse?) -> Void) {
+ api?.fetch(request: MarkAsReadRequest(rid: rid)) { response in
+ switch response {
+ case .resource(let response):
+ completion(response)
+
+ case .error:
+ completion(nil)
+ }
+ }
+ }
}