diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 78ef90213..1f8fd1749 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -408,7 +408,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) } - func notificationTapped(content: UNNotificationContent) async { + func notificationTapped(content: UNNotificationContent, isNewestInRoom: Bool) async { MXLog.info("Tapped Notification") guard let roomID = content.roomID, @@ -426,11 +426,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg handleAppRoute(.room(roomID: roomID, via: []), windowType: nil) } else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID { handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID), windowType: nil) - } else if let eventID { + } else if let eventID, !isNewestInRoom { // Only track main timeline event deeplinking analyticsService.signpost.startTransaction(.notificationToMessage) handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil) } else { + // The live timeline already ends with the newest message, so there's no need to focus on it. handleAppRoute(.room(roomID: roomID, via: []), windowType: nil) } } diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 9b8bf8188..132a729e7 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -259,11 +259,21 @@ extension NotificationManager: UNUserNotificationCenterDelegate { content: response.notification.request.content, replyText: response.userText) case UNNotificationDefaultActionIdentifier: - await delegate?.notificationTapped(content: response.notification.request.content) + await delegate?.notificationTapped(content: response.notification.request.content, + isNewestInRoom: isNewestInRoom(response.notification)) default: break } } + + /// Whether no other delivered notification for the same room is more recent. + private func isNewestInRoom(_ notification: UNNotification) async -> Bool { + guard let roomID = notification.request.content.roomID else { return false } + + return await notificationCenter.deliveredNotifications().allSatisfy { deliveredNotification in + deliveredNotification.request.content.roomID != roomID || deliveredNotification.date <= notification.date + } + } } extension UNUserNotificationCenter: UserNotificationCenterProtocol { } diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift index 98d1f7d59..a34032c22 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift @@ -11,7 +11,8 @@ import UserNotifications protocol NotificationManagerDelegate: AnyObject { func shouldDisplayInAppNotification(content: UNNotificationContent) -> Bool - func notificationTapped(content: UNNotificationContent) async + /// - Parameter isNewestInRoom: Whether no other delivered notification for the same room is more recent. + func notificationTapped(content: UNNotificationContent, isNewestInRoom: Bool) async func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index c2247acdf..a2dba2590 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -21,6 +21,7 @@ final class NotificationManagerTests { private var shouldDisplayInAppNotificationReturnValue = false private var handleInlineReplyDelegateCalled = false private var notificationTappedDelegateCalled = false + private var notificationTappedIsNewestInRoom: Bool? private var registerForRemoteNotificationsDelegateCalled: (() -> Void)? private let appSettings: AppSettings @@ -238,6 +239,31 @@ final class NotificationManagerTests { #expect(notificationTappedDelegateCalled) } + @Test + func whenTappedNotificationIsTheNewestInItsRoom_delegateIsToldItIsNewest() async throws { + notificationManager.delegate = self + let date = Date.now + notificationCenter.deliveredNotificationsReturnValue = try [UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: "!room:matrix.org"], date: date - 60), + UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: "!other:matrix.org"], date: date + 60)] + + let response = try UNTextInputNotificationResponse.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: "!room:matrix.org"], date: date) + await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response) + + #expect(notificationTappedIsNewestInRoom == true) + } + + @Test + func whenTappedNotificationIsOlderThanAnotherInItsRoom_delegateIsToldItIsNotNewest() async throws { + notificationManager.delegate = self + let date = Date.now + notificationCenter.deliveredNotificationsReturnValue = try [UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: "!room:matrix.org"], date: date + 60)] + + let response = try UNTextInputNotificationResponse.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: "!room:matrix.org"], date: date) + await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response) + + #expect(notificationTappedIsNewestInRoom == false) + } + @Test func updatingAppBadgeCountUsesTheClientSideCount() async { clientProxy.totalUnreadNotifications = 7 @@ -272,8 +298,9 @@ extension NotificationManagerTests: @MainActor NotificationManagerDelegate { shouldDisplayInAppNotificationReturnValue } - func notificationTapped(content: UNNotificationContent) async { + func notificationTapped(content: UNNotificationContent, isNewestInRoom: Bool) async { notificationTappedDelegateCalled = true + notificationTappedIsNewestInRoom = isNewestInRoom } func handleInlineReply(_ service: ElementX.NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { diff --git a/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift b/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift index eadfe3e29..1f8deaf1e 100644 --- a/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift +++ b/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift @@ -11,7 +11,7 @@ import UserNotifications import XCTest extension UNNotification { - static func with(userInfo: [AnyHashable: Any]) throws -> UNNotification { + static func with(userInfo: [AnyHashable: Any], date: Date? = nil) throws -> UNNotification { let content = UNMutableNotificationContent() content.userInfo = userInfo let request = UNNotificationRequest(identifier: "", @@ -20,6 +20,9 @@ extension UNNotification { let archiver = MockCoder(requiringSecureCoding: false) let notification = try XCTUnwrap(UNNotification(coder: archiver)) notification.setValue(request, forKey: "request") + if let date { + notification.setValue(date, forKey: "date") + } return notification } } diff --git a/UnitTests/Sources/NotificationManager/UNTextInputNotificationResponse+Creator.swift b/UnitTests/Sources/NotificationManager/UNTextInputNotificationResponse+Creator.swift index e5ba5be6c..a0544f9c9 100644 --- a/UnitTests/Sources/NotificationManager/UNTextInputNotificationResponse+Creator.swift +++ b/UnitTests/Sources/NotificationManager/UNTextInputNotificationResponse+Creator.swift @@ -10,15 +10,9 @@ import UserNotifications import XCTest extension UNTextInputNotificationResponse { - static func with(userInfo: [AnyHashable: Any], actionIdentifier: String = UNNotificationDefaultActionIdentifier) throws -> UNNotificationResponse { - let content = UNMutableNotificationContent() - content.userInfo = userInfo - let request = UNNotificationRequest(identifier: "", - content: content, - trigger: nil) + static func with(userInfo: [AnyHashable: Any], date: Date? = nil, actionIdentifier: String = UNNotificationDefaultActionIdentifier) throws -> UNNotificationResponse { + let notification = try UNNotification.with(userInfo: userInfo, date: date) let archiver = MockCoder(requiringSecureCoding: false) - let notification = try XCTUnwrap(UNNotification(coder: archiver)) - notification.setValue(request, forKey: "request") let response = try XCTUnwrap(UNTextInputNotificationResponse(coder: archiver)) response.setValue(notification, forKey: "notification")