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.
This commit is contained in:
Stefan Ceriu
2026-09-30 13:07:01 +03:00
parent 7973da8251
commit 4c71cae0f5
6 changed files with 50 additions and 14 deletions
@@ -408,7 +408,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID)
} }
func notificationTapped(content: UNNotificationContent) async { func notificationTapped(content: UNNotificationContent, isNewestInRoom: Bool) async {
MXLog.info("Tapped Notification") MXLog.info("Tapped Notification")
guard let roomID = content.roomID, guard let roomID = content.roomID,
@@ -426,11 +426,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
handleAppRoute(.room(roomID: roomID, via: []), windowType: nil) handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
} else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID { } else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID {
handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID), windowType: nil) 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 // Only track main timeline event deeplinking
analyticsService.signpost.startTransaction(.notificationToMessage) analyticsService.signpost.startTransaction(.notificationToMessage)
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil) handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil)
} else { } 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) handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
} }
} }
@@ -259,11 +259,21 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
content: response.notification.request.content, content: response.notification.request.content,
replyText: response.userText) replyText: response.userText)
case UNNotificationDefaultActionIdentifier: case UNNotificationDefaultActionIdentifier:
await delegate?.notificationTapped(content: response.notification.request.content) await delegate?.notificationTapped(content: response.notification.request.content,
isNewestInRoom: isNewestInRoom(response.notification))
default: default:
break 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 { } extension UNUserNotificationCenter: UserNotificationCenterProtocol { }
@@ -11,7 +11,8 @@ import UserNotifications
protocol NotificationManagerDelegate: AnyObject { protocol NotificationManagerDelegate: AnyObject {
func shouldDisplayInAppNotification(content: UNNotificationContent) -> Bool 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, func handleInlineReply(_ service: NotificationManagerProtocol,
content: UNNotificationContent, content: UNNotificationContent,
replyText: String) async replyText: String) async
@@ -21,6 +21,7 @@ final class NotificationManagerTests {
private var shouldDisplayInAppNotificationReturnValue = false private var shouldDisplayInAppNotificationReturnValue = false
private var handleInlineReplyDelegateCalled = false private var handleInlineReplyDelegateCalled = false
private var notificationTappedDelegateCalled = false private var notificationTappedDelegateCalled = false
private var notificationTappedIsNewestInRoom: Bool?
private var registerForRemoteNotificationsDelegateCalled: (() -> Void)? private var registerForRemoteNotificationsDelegateCalled: (() -> Void)?
private let appSettings: AppSettings private let appSettings: AppSettings
@@ -238,6 +239,31 @@ final class NotificationManagerTests {
#expect(notificationTappedDelegateCalled) #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 @Test
func updatingAppBadgeCountUsesTheClientSideCount() async { func updatingAppBadgeCountUsesTheClientSideCount() async {
clientProxy.totalUnreadNotifications = 7 clientProxy.totalUnreadNotifications = 7
@@ -272,8 +298,9 @@ extension NotificationManagerTests: @MainActor NotificationManagerDelegate {
shouldDisplayInAppNotificationReturnValue shouldDisplayInAppNotificationReturnValue
} }
func notificationTapped(content: UNNotificationContent) async { func notificationTapped(content: UNNotificationContent, isNewestInRoom: Bool) async {
notificationTappedDelegateCalled = true notificationTappedDelegateCalled = true
notificationTappedIsNewestInRoom = isNewestInRoom
} }
func handleInlineReply(_ service: ElementX.NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { func handleInlineReply(_ service: ElementX.NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
@@ -11,7 +11,7 @@ import UserNotifications
import XCTest import XCTest
extension UNNotification { extension UNNotification {
static func with(userInfo: [AnyHashable: Any]) throws -> UNNotification { static func with(userInfo: [AnyHashable: Any], date: Date? = nil) throws -> UNNotification {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.userInfo = userInfo content.userInfo = userInfo
let request = UNNotificationRequest(identifier: "", let request = UNNotificationRequest(identifier: "",
@@ -20,6 +20,9 @@ extension UNNotification {
let archiver = MockCoder(requiringSecureCoding: false) let archiver = MockCoder(requiringSecureCoding: false)
let notification = try XCTUnwrap(UNNotification(coder: archiver)) let notification = try XCTUnwrap(UNNotification(coder: archiver))
notification.setValue(request, forKey: "request") notification.setValue(request, forKey: "request")
if let date {
notification.setValue(date, forKey: "date")
}
return notification return notification
} }
} }
@@ -10,15 +10,9 @@ import UserNotifications
import XCTest import XCTest
extension UNTextInputNotificationResponse { extension UNTextInputNotificationResponse {
static func with(userInfo: [AnyHashable: Any], actionIdentifier: String = UNNotificationDefaultActionIdentifier) throws -> UNNotificationResponse { static func with(userInfo: [AnyHashable: Any], date: Date? = nil, actionIdentifier: String = UNNotificationDefaultActionIdentifier) throws -> UNNotificationResponse {
let content = UNMutableNotificationContent() let notification = try UNNotification.with(userInfo: userInfo, date: date)
content.userInfo = userInfo
let request = UNNotificationRequest(identifier: "",
content: content,
trigger: nil)
let archiver = MockCoder(requiringSecureCoding: false) let archiver = MockCoder(requiringSecureCoding: false)
let notification = try XCTUnwrap(UNNotification(coder: archiver))
notification.setValue(request, forKey: "request")
let response = try XCTUnwrap(UNTextInputNotificationResponse(coder: archiver)) let response = try XCTUnwrap(UNTextInputNotificationResponse(coder: archiver))
response.setValue(notification, forKey: "notification") response.setValue(notification, forKey: "notification")