Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
android:enabled="true"
android:exported="true" >
</receiver>
<receiver
android:name="chat.rocket.reactnative.notification.MarkAsReadBroadcast"
android:enabled="true"
android:exported="false" />
<receiver
android:name="chat.rocket.reactnative.notification.VideoConfBroadcast"
android:enabled="true"
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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";

@Override
public void onReceive(Context context, Intent intent) {
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);
} catch (NumberFormatException e) {
Log.e(TAG, "Invalid notification ID: " + notId, e);
}
}

protected void markAsRead(final Ejson ejson, final int notId, final NotificationManager notificationManager) {
String serverURL = ejson.serverURL();
String rid = ejson.rid;

if (serverURL == null || rid == null) {
Log.e(TAG, "Missing serverURL or rid");
return;
}

final OkHttpClient client = new OkHttpClient();
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");

String json = String.format("{\"rid\":\"%s\"}", rid);

CustomPushNotification.clearMessages(notId);

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());
}

@Override
public void onResponse(Call call, final Response response) throws IOException {
if (response.isSuccessful()) {
Log.d(TAG, "Mark as read SUCCESS");
notificationManager.cancel(notId);
} else {
Log.e(TAG, String.format("Mark as read FAILED status %s", response.code()));
}
}
});
}
}
1 change: 1 addition & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion app/lib/notifications/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const setupNotificationCategories = async (): Promise<void> => {
}

try {
// Message category with Reply action
// Message category with Reply and Mark as Read actions
await Notifications.setNotificationCategoryAsync('MESSAGE', [
{
identifier: 'REPLY_ACTION',
Expand All @@ -100,6 +100,13 @@ const setupNotificationCategories = async (): Promise<void> => {
options: {
opensAppToForeground: false
}
},
{
identifier: 'MARK_AS_READ_ACTION',
buttonTitle: I18n.t('Mark_as_read'),
options: {
opensAppToForeground: false
}
}
]);

Expand Down
56 changes: 42 additions & 14 deletions ios/ReplyNotification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,15 +40,21 @@ 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)
} else {
completionHandler()
}
}

func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
Expand All @@ -61,7 +67,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler([])
}
}

func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
Expand All @@ -73,37 +79,37 @@ 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),
let rid = payload.rid else {
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 {
defer {
UIApplication.shared.endBackgroundTask(backgroundTask)
completionHandler()
}

guard let response = response, response.success else {
// Show failure notification
let content = UNMutableNotificationContent()
Expand All @@ -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()
}
}
}
}
34 changes: 34 additions & 0 deletions ios/Shared/RocketChat/API/Requests/MarkAsRead.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
37 changes: 25 additions & 12 deletions ios/Shared/RocketChat/RocketChat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,50 +11,50 @@ 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)) {
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)) {
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
Expand All @@ -65,19 +65,32 @@ 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)
break
}
}
}
}