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) + } + } + } }