From 2182446684384d5d552ca1a17acc8046ef6452d9 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 6 Oct 2026 15:40:24 +0300 Subject: [PATCH] Clear notifications for fully read rooms using the event's timestamp instead of delivery date --- .../Mocks/NotificationItemProxyMock.swift | 1 + .../Extensions/UNNotificationContent.swift | 13 +++++++++++ .../Manager/NotificationManager.swift | 5 +++- .../Notification/NotificationConstants.swift | 1 + NSE/Sources/NotificationContentBuilder.swift | 1 + .../NotificationContentBuilderTests.swift | 2 ++ .../NotificationManagerTests.swift | 23 +++++++++++++++++++ .../UNNotification+Creator.swift | 5 ++-- 8 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ElementX/Sources/Mocks/NotificationItemProxyMock.swift b/ElementX/Sources/Mocks/NotificationItemProxyMock.swift index 96f3bf56e..8cc8afddf 100644 --- a/ElementX/Sources/Mocks/NotificationItemProxyMock.swift +++ b/ElementX/Sources/Mocks/NotificationItemProxyMock.swift @@ -17,6 +17,7 @@ struct NotificationItemProxyMockConfiguration { let messageLikeContent = MessageLikeEventContent.roomMessage(messageType: messageType, inReplyToEventId: nil) let event = TimelineEventSDKMock() event.eventIdReturnValue = UUID().uuidString + event.timestampReturnValue = 0 event.contentReturnValue = .messageLike(content: messageLikeContent) return .timeline(event: event) }() diff --git a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift index a458e2740..7189eac1b 100644 --- a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift +++ b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift @@ -22,6 +22,10 @@ nonisolated extension UNNotificationContent { userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String } + @objc var eventDate: Date? { + userInfo[NotificationConstants.UserInfoKey.eventDate] as? Date + } + @objc var pusherNotificationClientIdentifier: String? { userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String } @@ -59,6 +63,15 @@ nonisolated extension UNMutableNotificationContent { } } + override var eventDate: Date? { + get { + userInfo[NotificationConstants.UserInfoKey.eventDate] as? Date + } + set { + userInfo[NotificationConstants.UserInfoKey.eventDate] = newValue + } + } + override var threadRootEventID: String? { get { userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 9b8bf8188..d9f939621 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -151,7 +151,10 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { return false } - return notification.date <= lastMessageDate + // The delivery date is later than the event's so only use it for notifications without an event date. + let notificationDate = notification.request.content.eventDate ?? notification.date + // Truncated to seconds to match `RoomSummary.lastMessageDate`. + return notificationDate.timeIntervalSince1970.rounded(.down) <= lastMessageDate.timeIntervalSince1970 } .map(\.request.identifier) diff --git a/ElementX/Sources/Services/Notification/NotificationConstants.swift b/ElementX/Sources/Services/Notification/NotificationConstants.swift index 8b083fb82..5e5ae788a 100644 --- a/ElementX/Sources/Services/Notification/NotificationConstants.swift +++ b/ElementX/Sources/Services/Notification/NotificationConstants.swift @@ -12,6 +12,7 @@ nonisolated enum NotificationConstants { enum UserInfoKey { static let roomIdentifier = "room_id" static let eventIdentifier = "event_id" + static let eventDate = "event_date" static let threadRootEventIdentifier = "thread_root_event_id" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let receiverIdentifier = "receiver_id" diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index c4ed82f6b..98c331fbd 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -33,6 +33,7 @@ nonisolated struct NotificationContentBuilder { switch notificationItem.event { case .timeline(let event): notificationContent.eventID = event.eventId() + notificationContent.eventDate = Date(timeIntervalSince1970: TimeInterval(event.timestamp()) / 1000) case .invite, .none: notificationContent.eventID = nil } diff --git a/UnitTests/Sources/NotificationContentBuilderTests.swift b/UnitTests/Sources/NotificationContentBuilderTests.swift index d7278a459..8e49cbd89 100644 --- a/UnitTests/Sources/NotificationContentBuilderTests.swift +++ b/UnitTests/Sources/NotificationContentBuilderTests.swift @@ -226,6 +226,7 @@ nonisolated struct NotificationContentBuilderTests { mutating func liveLocationStartNotification() async { let event = TimelineEventSDKMock() event.eventIdReturnValue = UUID().uuidString + event.timestampReturnValue = 0 event.contentReturnValue = .state(content: .beaconInfo) let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), @@ -253,6 +254,7 @@ nonisolated struct NotificationContentBuilderTests { mutating func otherStateEventNotification() async { let event = TimelineEventSDKMock() event.eventIdReturnValue = UUID().uuidString + event.timestampReturnValue = 0 event.contentReturnValue = .state(content: .roomName) let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index c2247acdf..1ff5b09c7 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -8,6 +8,7 @@ import Combine @testable import ElementX +import MatrixRustSDKMocks import NotificationCenter import Testing @@ -192,6 +193,28 @@ final class NotificationManagerTests { #expect(authorizationStatusWasGranted) } + @Test + func removeDeliveredNotificationsForFullyReadRooms() async throws { + let room = RoomSummary(room: RoomSDKMock(), id: "!room:matrix.org", settingsMode: .allMessages, + hasUnreadMessages: false, hasUnreadMentions: false, hasUnreadNotifications: false) + let lastMessageDate = try #require(room.lastMessageDate) + + // Delivered after the last message was sent, which is always the case for the last message's notification. + let readNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, + NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(0.5)], + identifier: "read", + date: lastMessageDate.addingTimeInterval(5)) + let newerNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, + NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(1)], + identifier: "newer", + date: lastMessageDate.addingTimeInterval(5)) + notificationCenter.deliveredNotificationsReturnValue = [readNotification, newerNotification] + + await notificationManager.removeDeliveredNotificationsForFullyReadRooms([room]) + + #expect(notificationCenter.removeDeliveredNotificationsWithIdentifiersReceivedIdentifiers == ["read"]) + } + @Test func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws { let archiver = MockCoder(requiringSecureCoding: false) diff --git a/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift b/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift index eadfe3e29..a84b103f5 100644 --- a/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift +++ b/UnitTests/Sources/NotificationManager/UNNotification+Creator.swift @@ -11,15 +11,16 @@ import UserNotifications import XCTest extension UNNotification { - static func with(userInfo: [AnyHashable: Any]) throws -> UNNotification { + static func with(userInfo: [AnyHashable: Any], identifier: String = "", date: Date = .now) throws -> UNNotification { let content = UNMutableNotificationContent() content.userInfo = userInfo - let request = UNNotificationRequest(identifier: "", + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) let archiver = MockCoder(requiringSecureCoding: false) let notification = try XCTUnwrap(UNNotification(coder: archiver)) notification.setValue(request, forKey: "request") + notification.setValue(date, forKey: "date") return notification } }