Remove delivered notifications for rooms that have meantime become fully read and have messages newer than what's delivered

- this is a best effort solution to cleanup outdated notifications when using multiple clients and reading them elsewhere
This commit is contained in:
Stefan Ceriu
2025-04-17 15:01:01 +03:00
committed by Stefan Ceriu
parent e14672e1f6
commit 899c33a5ad
12 changed files with 128 additions and 29 deletions
@@ -387,39 +387,22 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
userSession.clientProxy.actionsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self, case let .receivedDecryptionError(info) = action else {
return
}
guard let self else { return }
let timeToDecryptMs: Int
if let unsignedTimeToDecryptMs = info.timeToDecryptMs {
timeToDecryptMs = Int(unsignedTimeToDecryptMs)
} else {
timeToDecryptMs = -1
switch action {
case .receivedDecryptionError(let info):
processDecryptionError(info)
case .receivedSyncUpdate:
Task {
let roomSummaries = self.userSession.clientProxy.staticRoomSummaryProvider.roomListPublisher.value
await self.notificationManager.removeDeliveredNotificationsForFullyReadRooms(roomSummaries)
}
default:
break
}
let errorName: AnalyticsEvent.Error.Name = switch info.cause {
case .unknown: .OlmKeysNotSentError
case .unknownDevice, .unsignedDevice: .ExpectedSentByInsecureDevice
case .verificationViolation: .ExpectedVerificationViolation
case .sentBeforeWeJoined: .ExpectedDueToMembership
case .historicalMessageAndBackupIsDisabled, .historicalMessageAndDeviceIsUnverified: .HistoricalMessage
case .withheldForUnverifiedOrInsecureDevice: .RoomKeysWithheldForUnverifiedDevice
case .withheldBySender: .OlmKeysNotSentError
}
analytics.trackError(context: nil,
domain: .E2EE,
name: errorName,
timeToDecryptMillis: timeToDecryptMs,
eventLocalAgeMillis: Int(truncatingIfNeeded: info.eventLocalAgeMillis),
isFederated: info.ownHomeserver != info.senderHomeserver,
isMatrixDotOrg: info.ownHomeserver == "matrix.org",
userTrustsOwnIdentity: info.userTrustsOwnIdentity,
wasVisibleToUser: nil)
}
.store(in: &cancellables)
elementCallService.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
@@ -444,6 +427,35 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables)
}
private func processDecryptionError(_ info: UnableToDecryptInfo) {
let timeToDecryptMs: Int
if let unsignedTimeToDecryptMs = info.timeToDecryptMs {
timeToDecryptMs = Int(unsignedTimeToDecryptMs)
} else {
timeToDecryptMs = -1
}
let errorName: AnalyticsEvent.Error.Name = switch info.cause {
case .unknown: .OlmKeysNotSentError
case .unknownDevice, .unsignedDevice: .ExpectedSentByInsecureDevice
case .verificationViolation: .ExpectedVerificationViolation
case .sentBeforeWeJoined: .ExpectedDueToMembership
case .historicalMessageAndBackupIsDisabled, .historicalMessageAndDeviceIsUnverified: .HistoricalMessage
case .withheldForUnverifiedOrInsecureDevice: .RoomKeysWithheldForUnverifiedDevice
case .withheldBySender: .OlmKeysNotSentError
}
analytics.trackError(context: nil,
domain: .E2EE,
name: errorName,
timeToDecryptMillis: timeToDecryptMs,
eventLocalAgeMillis: Int(truncatingIfNeeded: info.eventLocalAgeMillis),
isFederated: info.ownHomeserver != info.senderHomeserver,
isMatrixDotOrg: info.ownHomeserver == "matrix.org",
userTrustsOwnIdentity: info.userTrustsOwnIdentity,
wasVisibleToUser: nil)
}
private func setupSessionVerificationRequestsObserver() {
userSession.clientProxy.sessionVerificationController?.actions
.receive(on: DispatchQueue.main)
@@ -12026,6 +12026,47 @@ class NotificationManagerMock: NotificationManagerProtocol, @unchecked Sendable
}
await removeDeliveredMessageNotificationsForClosure?(roomID)
}
//MARK: - removeDeliveredNotificationsForFullyReadRooms
var removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount = 0
var removeDeliveredNotificationsForFullyReadRoomsCallsCount: Int {
get {
if Thread.isMainThread {
return removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount = newValue
}
}
}
}
var removeDeliveredNotificationsForFullyReadRoomsCalled: Bool {
return removeDeliveredNotificationsForFullyReadRoomsCallsCount > 0
}
var removeDeliveredNotificationsForFullyReadRoomsReceivedRooms: [RoomSummary]?
var removeDeliveredNotificationsForFullyReadRoomsReceivedInvocations: [[RoomSummary]] = []
var removeDeliveredNotificationsForFullyReadRoomsClosure: (([RoomSummary]) async -> Void)?
func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async {
removeDeliveredNotificationsForFullyReadRoomsCallsCount += 1
removeDeliveredNotificationsForFullyReadRoomsReceivedRooms = rooms
DispatchQueue.main.async {
self.removeDeliveredNotificationsForFullyReadRoomsReceivedInvocations.append(rooms)
}
await removeDeliveredNotificationsForFullyReadRoomsClosure?(rooms)
}
}
class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol, @unchecked Sendable {
var callbacks: PassthroughSubject<NotificationSettingsProxyCallback, Never> {
@@ -79,6 +79,7 @@ extension RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("I do not wish to take the trouble to understand mysticism"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "14:56",
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -102,6 +103,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("I do not wish to take the trouble to understand mysticism"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "14:56",
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -120,6 +122,7 @@ extension Array where Element == RoomSummary {
avatarURL: .mockMXCAvatar,
heroes: [],
lastMessage: AttributedString("How do you see the Emperor then? You think he keeps office hours?"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "2:56 PM",
unreadMessagesCount: 2,
unreadMentionsCount: 0,
@@ -138,6 +141,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: try? AttributedString(markdown: "He certainly seemed no *mental genius* to me"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "Some time ago",
unreadMessagesCount: 3,
unreadMentionsCount: 0,
@@ -156,6 +160,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("There's an archaic measure of time that's called the month"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "Just now",
unreadMessagesCount: 2,
unreadMentionsCount: 2,
@@ -174,6 +179,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("Clearly, if Earth is powerful enough to do that, it might also be capable of adjusting minds in order to force belief in its radioactivity"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "1986",
unreadMessagesCount: 1,
unreadMentionsCount: 1,
@@ -192,6 +198,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("Are you groping for the word 'paranoia'?"),
lastMessageDate: nil,
lastMessageFormattedTimestamp: "きょうはじゅういちがつじゅういちにちです",
unreadMessagesCount: 6,
unreadMentionsCount: 0,
@@ -210,6 +217,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -261,6 +269,7 @@ extension Array where Element == RoomSummary {
avatarURL: .mockMXCAvatar,
heroes: [],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -279,6 +288,7 @@ extension Array where Element == RoomSummary {
avatarURL: nil,
heroes: [],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -207,6 +207,7 @@ private extension HomeScreenRoom {
avatarURL: nil,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -235,6 +236,7 @@ private extension HomeScreenRoom {
avatarURL: avatarURL,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -152,6 +152,7 @@ private extension HomeScreenRoom {
avatarURL: nil,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -180,6 +181,7 @@ private extension HomeScreenRoom {
avatarURL: avatarURL,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageDate: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
@@ -128,6 +128,28 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
.map(\.request.identifier)
notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers)
}
func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async {
let roomsToLastMessageDates = rooms
.filter { $0.hasUnreadMessages == false }
.reduce(into: [:]) { partialResult, roomSummary in
partialResult[roomSummary.id] = roomSummary.lastMessageDate
}
let notificationsIdentifiers = await notificationCenter
.deliveredNotifications()
.filter { notification in
guard let roomID = notification.request.content.roomID,
let lastMessageDate = roomsToLastMessageDates[roomID] else {
return false
}
return notification.date <= lastMessageDate
}
.map(\.request.identifier)
notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers)
}
private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool {
do {
@@ -33,4 +33,6 @@ protocol NotificationManagerProtocol: AnyObject {
func requestAuthorization()
func removeDeliveredMessageNotifications(for roomID: String) async
func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async
}
@@ -40,6 +40,7 @@ struct RoomSummary {
let avatarURL: URL?
let heroes: [UserProfileProxy]
let lastMessage: AttributedString?
let lastMessageDate: Date?
let lastMessageFormattedTimestamp: String?
let unreadMessagesCount: UInt
let unreadMentionsCount: UInt
@@ -95,6 +96,7 @@ extension RoomSummary {
avatarURL = nil
heroes = []
lastMessage = AttributedString(string)
lastMessageDate = .now
lastMessageFormattedTimestamp = "Now"
unreadMessagesCount = hasUnreadMessages ? 1 : 0
unreadMentionsCount = hasUnreadMentions ? 1 : 0
@@ -242,10 +242,12 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
}
var attributedLastMessage: AttributedString?
var lastMessageDate: Date?
var lastMessageFormattedTimestamp: String?
if let latestRoomMessage = roomDetails.latestEvent {
let lastMessage = EventTimelineItemProxy(item: latestRoomMessage, uniqueID: .init("0"))
lastMessageDate = lastMessage.timestamp
lastMessageFormattedTimestamp = lastMessage.timestamp.formattedMinimal()
attributedLastMessage = eventStringBuilder.buildAttributedString(for: lastMessage)
}
@@ -271,6 +273,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
avatarURL: roomInfo.avatarUrl.flatMap(URL.init(string:)),
heroes: roomInfo.heroes.map(UserProfileProxy.init),
lastMessage: attributedLastMessage,
lastMessageDate: lastMessageDate,
lastMessageFormattedTimestamp: lastMessageFormattedTimestamp,
unreadMessagesCount: UInt(roomInfo.numUnreadMessages),
unreadMentionsCount: UInt(roomInfo.numUnreadMentions),