Clear notifications for fully read rooms using the event's timestamp instead of delivery date

This commit is contained in:
Stefan Ceriu
2026-10-06 15:40:24 +03:00
parent fc75f90ef5
commit 2182446684
8 changed files with 48 additions and 3 deletions
@@ -17,6 +17,7 @@ struct NotificationItemProxyMockConfiguration {
let messageLikeContent = MessageLikeEventContent.roomMessage(messageType: messageType, inReplyToEventId: nil) let messageLikeContent = MessageLikeEventContent.roomMessage(messageType: messageType, inReplyToEventId: nil)
let event = TimelineEventSDKMock() let event = TimelineEventSDKMock()
event.eventIdReturnValue = UUID().uuidString event.eventIdReturnValue = UUID().uuidString
event.timestampReturnValue = 0
event.contentReturnValue = .messageLike(content: messageLikeContent) event.contentReturnValue = .messageLike(content: messageLikeContent)
return .timeline(event: event) return .timeline(event: event)
}() }()
@@ -22,6 +22,10 @@ nonisolated extension UNNotificationContent {
userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String
} }
@objc var eventDate: Date? {
userInfo[NotificationConstants.UserInfoKey.eventDate] as? Date
}
@objc var pusherNotificationClientIdentifier: String? { @objc var pusherNotificationClientIdentifier: String? {
userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? 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? { override var threadRootEventID: String? {
get { get {
userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String
@@ -151,7 +151,10 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
return false 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) .map(\.request.identifier)
@@ -12,6 +12,7 @@ nonisolated enum NotificationConstants {
enum UserInfoKey { enum UserInfoKey {
static let roomIdentifier = "room_id" static let roomIdentifier = "room_id"
static let eventIdentifier = "event_id" static let eventIdentifier = "event_id"
static let eventDate = "event_date"
static let threadRootEventIdentifier = "thread_root_event_id" static let threadRootEventIdentifier = "thread_root_event_id"
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
static let receiverIdentifier = "receiver_id" static let receiverIdentifier = "receiver_id"
@@ -33,6 +33,7 @@ nonisolated struct NotificationContentBuilder {
switch notificationItem.event { switch notificationItem.event {
case .timeline(let event): case .timeline(let event):
notificationContent.eventID = event.eventId() notificationContent.eventID = event.eventId()
notificationContent.eventDate = Date(timeIntervalSince1970: TimeInterval(event.timestamp()) / 1000)
case .invite, .none: case .invite, .none:
notificationContent.eventID = nil notificationContent.eventID = nil
} }
@@ -226,6 +226,7 @@ nonisolated struct NotificationContentBuilderTests {
mutating func liveLocationStartNotification() async { mutating func liveLocationStartNotification() async {
let event = TimelineEventSDKMock() let event = TimelineEventSDKMock()
event.eventIdReturnValue = UUID().uuidString event.eventIdReturnValue = UUID().uuidString
event.timestampReturnValue = 0
event.contentReturnValue = .state(content: .beaconInfo) event.contentReturnValue = .state(content: .beaconInfo)
let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event),
@@ -253,6 +254,7 @@ nonisolated struct NotificationContentBuilderTests {
mutating func otherStateEventNotification() async { mutating func otherStateEventNotification() async {
let event = TimelineEventSDKMock() let event = TimelineEventSDKMock()
event.eventIdReturnValue = UUID().uuidString event.eventIdReturnValue = UUID().uuidString
event.timestampReturnValue = 0
event.contentReturnValue = .state(content: .roomName) event.contentReturnValue = .state(content: .roomName)
let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event),
@@ -8,6 +8,7 @@
import Combine import Combine
@testable import ElementX @testable import ElementX
import MatrixRustSDKMocks
import NotificationCenter import NotificationCenter
import Testing import Testing
@@ -192,6 +193,28 @@ final class NotificationManagerTests {
#expect(authorizationStatusWasGranted) #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 @Test
func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws { func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws {
let archiver = MockCoder(requiringSecureCoding: false) let archiver = MockCoder(requiringSecureCoding: false)
@@ -11,15 +11,16 @@ import UserNotifications
import XCTest import XCTest
extension UNNotification { 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() let content = UNMutableNotificationContent()
content.userInfo = userInfo content.userInfo = userInfo
let request = UNNotificationRequest(identifier: "", let request = UNNotificationRequest(identifier: identifier,
content: content, content: content,
trigger: nil) trigger: nil)
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")
notification.setValue(date, forKey: "date")
return notification return notification
} }
} }