From 4c71cae0f53bc0128a00dec4049926e4fcad5727 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 30 Sep 2026 13:07:01 +0300 Subject: [PATCH] Open the room live when tapping the notification for its newest message Tapping a message notification always opened the room on a timeline focussed on that event, even when it was the newest message and the live timeline already ends with it. On tap, the notification manager now checks whether any other delivered notification for the same room is more recent. If none is, the room is opened live instead of focussed. Older notifications in a room keep focussing on their event. --- .../Sources/Application/AppCoordinator.swift | 5 ++-- .../Manager/NotificationManager.swift | 12 +++++++- .../Manager/NotificationManagerProtocol.swift | 3 +- .../NotificationManagerTests.swift | 29 ++++++++++++++++++- .../UNNotification+Creator.swift | 5 +++- ...extInputNotificationResponse+Creator.swift | 10 ++----- 6 files changed, 50 insertions(+), 14 deletions(-) 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")