From dfa5214136a64d18434a2ac9a9b9bcaa3f603d60 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:28:45 +0200 Subject: [PATCH] Threaded notifications (#4644) * implemented grouping * implemented presenting the thread * improved the implementation by reusing the presentation action * add Thread group to DMs to differentiate from non threaded messages of the same DM * name for a threaded notification in group room * focus event when tapping on a notification * pr suggestions * document --- .../en.lproj/Localizable.strings | 1 + .../Sources/Application/AppCoordinator.swift | 9 +- .../Application/Navigation/AppRoutes.swift | 2 + .../ChatsFlowCoordinator.swift | 49 ++++++---- .../EncryptionSettingsFlowCoordinator.swift | 2 +- .../RoomFlowCoordinator.swift | 89 ++++++++++++------- .../RoomFlowCoordinatorStateMachine.swift | 10 +++ .../UserSessionFlowCoordinator.swift | 2 +- ElementX/Sources/Generated/Strings.swift | 4 + .../Extensions/UNNotificationContent.swift | 13 +++ .../TimelineTableViewController.swift | 5 +- .../Notification/NotificationConstants.swift | 1 + .../Proxy/NotificationItemProxy.swift | 6 ++ .../Proxy/NotificationItemProxyProtocol.swift | 2 + NSE/Sources/NSEUserSession.swift | 2 + NSE/Sources/NotificationContentBuilder.swift | 31 +++++-- 16 files changed, 166 insertions(+), 62 deletions(-) diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 52f4f328d..7622dad42 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -420,6 +420,7 @@ "leave_room_alert_select_new_owner_title" = "Transfer ownership"; "leave_room_alert_subtitle" = "Are you sure that you want to leave the room?"; "login_initial_device_name_ios" = "%1$@ iOS"; +"notification_thread_in_room" = "Thread in %1$@"; "notification_channel_call" = "Call"; "notification_channel_listening_for_events" = "Listening for events"; "notification_channel_noisy" = "Noisy notifications"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 70d53903a..bc8a9ecd4 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -359,9 +359,14 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } else { storedRoomsToAwait = [roomID] } + handleAppRoute(.room(roomID: roomID, via: [])) + } else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID { + handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: content.eventID)) + } else if let eventID = content.eventID { + handleAppRoute(.event(eventID: eventID, roomID: roomID, via: [])) + } else { + handleAppRoute(.room(roomID: roomID, via: [])) } - - handleAppRoute(.room(roomID: roomID, via: [])) } func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index ea3ddbab6..4d12c0293 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -51,6 +51,8 @@ enum AppRoute: Hashable { case share(ShareExtensionPayload) /// The change roles screen of a room with the transfer ownership setting case transferOwnership(roomID: String) + /// A thread within a room, only to be used to handle tap on notification for threaded events. + case thread(roomID: String, threadRootEventID: String, focusEventID: String?) /// Whether or not the route should be handled by the authentication flow. var isAuthenticationRoute: Bool { diff --git a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift index 4398120bd..350ae3191 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift @@ -130,6 +130,12 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { roomFlowCoordinator?.clearRoute(animated: animated) case .roomMemberDetails: roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) + case .thread(let roomID, let threadRootEventID, let focusEventID): + stateMachine.processEvent(.selectRoom(roomID: roomID, + via: [], + entryPoint: .thread(rootEventID: threadRootEventID, + focusEventID: focusEventID)), + userInfo: .init(animated: animated)) case .event(let eventID, let roomID, let via): stateMachine.processEvent(.selectRoom(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: .init(animated: animated)) case .eventOnRoomAlias(let eventID, let alias): @@ -190,24 +196,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { case (.initial, .start, .roomList): presentHomeScreen() case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList): - if case .room(roomID) = detailState, - !entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading. - let roomFlowCoordinator { - let route: AppRoute = switch entryPoint { - case .room: .room(roomID: roomID, via: via) - case .roomDetails: .roomDetails(roomID: roomID) - case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored. - case .share(let payload): .share(payload) - case .transferOwnership: .transferOwnership(roomID: roomID) - } - roomFlowCoordinator.handleAppRoute(route, animated: animated) - } else { - if case .space = detailState { - dismissRoomFlow(animated: animated) - } - startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated) - } - actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user. + handleSelectRoomTransition(roomID: roomID, via: via, entryPoint: entryPoint, detailState: detailState, animated: animated) case(.roomList, .deselectRoom, .roomList): dismissRoomFlow(animated: animated) @@ -298,6 +287,28 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } } + private func handleSelectRoomTransition(roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint, detailState: ChatsFlowCoordinatorStateMachine.DetailState?, animated: Bool) { + if case .room(roomID) = detailState, + !entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading. + let roomFlowCoordinator { + let route: AppRoute = switch entryPoint { + case .room: .room(roomID: roomID, via: via) + case .roomDetails: .roomDetails(roomID: roomID) + case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored. + case .share(let payload): .share(payload) + case .transferOwnership: .transferOwnership(roomID: roomID) + case .thread(let rootEventID, let focusEventID): .thread(roomID: roomID, threadRootEventID: rootEventID, focusEventID: focusEventID) + } + roomFlowCoordinator.handleAppRoute(route, animated: animated) + } else { + if case .space = detailState { + dismissRoomFlow(animated: animated) + } + startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated) + } + actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user. + } + private func setupObservers() { userSession.clientProxy.actionsPublisher .receive(on: DispatchQueue.main) @@ -498,6 +509,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { coordinator.handleAppRoute(.share(payload), animated: animated) case .transferOwnership: coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: animated) + case .thread(let rootEventID, let focusEventID): + coordinator.handleAppRoute(.thread(roomID: roomID, threadRootEventID: rootEventID, focusEventID: focusEventID), animated: animated) } Task { diff --git a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift index 75e06ed7c..17fb90506 100644 --- a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift @@ -81,7 +81,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol { case .accountProvisioningLink: break // We always ignore this flow when logged in. case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, - .roomDetails, .roomMemberDetails, .userProfile, + .roomDetails, .roomMemberDetails, .userProfile, .thread, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, .call, .genericCallLink, .settings, .share, .transferOwnership: // These routes aren't in this flow so clear the entire stack. diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index fc01f01c3..dcb40d898 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -37,6 +37,9 @@ enum RoomFlowCoordinatorEntryPoint: Hashable { case room /// The flow will start by showing the room, focussing on the supplied event ID. case eventID(String) + /// The flow will start by showing a thread timeline, can only be triggered by notification taps, + /// which means it can never be a used for child flows. + case thread(rootEventID: String, focusEventID: String?) /// The flow will start by showing the room's details. case roomDetails /// An external media share request @@ -154,6 +157,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) } + case .thread(let roomID, let threadRootEventID, let focusEventID): + Task { + await handleRoomRoute(roomID: roomID, + via: [], + presentationAction: .thread(rootEventID: threadRootEventID, + focusEventID: focusEventID), + animated: animated) + } case .event(let eventID, let roomID, let via): Task { await handleRoomRoute(roomID: roomID, @@ -162,38 +173,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { animated: animated) } case .childEvent(let eventID, let roomID, let via): - if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator { - childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated) - } else if roomID != roomProxy.id { - stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) - } else { - showLoadingIndicator(delay: .seconds(0.5)) - Task { - defer { hideLoadingIndicator() } - switch await roomProxy.loadOrFetchEventDetails(for: eventID) { - case .success(let event): - if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { - if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last { - threadCoordinator.focusOnEvent(eventID: eventID) - } else { - // If we are showing the room timeline, we want to focus the thread root. - if childThreadScreenCoordinators.isEmpty { - roomScreenCoordinator?.focusOnEvent(.init(eventID: threadRootEventID, shouldSetPin: false)) - } - stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: eventID)) - } - } else if !childThreadScreenCoordinators.isEmpty { - // If we are showing a child thread and we are navigating to a non threaded event - // of the same room, we want to push the room on top of the thread. - stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) - } else { - roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) - } - case .failure: - showErrorIndicator() - } - } - } + handleChildEventRoute(eventID: eventID, roomID: roomID, via: via, animated: animated) case .share(let payload): guard let roomID = payload.roomID, roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") @@ -226,6 +206,41 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } + private func handleChildEventRoute(eventID: String, roomID: String, via: [String], animated: Bool) { + if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator { + childRoomFlowCoordinator.handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via), animated: animated) + } else if roomID != roomProxy.id { + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) + } else { + showLoadingIndicator(delay: .seconds(0.5)) + Task { + defer { hideLoadingIndicator() } + switch await roomProxy.loadOrFetchEventDetails(for: eventID) { + case .success(let event): + if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { + if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last { + threadCoordinator.focusOnEvent(eventID: eventID) + } else { + // If we are showing the room timeline, we want to focus the thread root. + if childThreadScreenCoordinators.isEmpty { + roomScreenCoordinator?.focusOnEvent(.init(eventID: threadRootEventID, shouldSetPin: false)) + } + stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: eventID)) + } + } else if !childThreadScreenCoordinators.isEmpty { + // If we are showing a child thread and we are navigating to a non threaded event + // of the same room, we want to push the room on top of the thread. + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) + } else { + roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) + } + case .failure: + showErrorIndicator() + } + } + } + } + private func presentTransferOwnershipScreen() { let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner, roomProxy: roomProxy, @@ -281,8 +296,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) { case .success(let event): if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { - stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: threadRootEventID, shouldSetPin: false))), userInfo: EventUserInfo(animated: animated)) - stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEvent.eventID), userInfo: EventUserInfo(animated: false)) + stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEventID: focusEvent.eventID)), userInfo: EventUserInfo(animated: animated)) } else { stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated)) } @@ -552,6 +566,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) case .share(.text(_, let text)): roomScreenCoordinator?.shareText(text) + case .thread(let rootEventID, let focusEventID): + roomScreenCoordinator?.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false)) + stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) case .none: break } @@ -586,6 +603,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .share(.mediaFiles(_, let mediaFiles)): stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)), userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) + case .thread(let rootEventID, let focusEventID): + stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) case .share(.text), .eventFocus: break // These are both handled in the coordinator's init. case .none: @@ -1564,6 +1583,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { coordinator.handleAppRoute(.share(payload), animated: true) case .transferOwnership: coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true) + case .thread: + fatalError("This entry point is not allowed for child flows") } } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index 5b5f91621..97f813c1c 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -25,11 +25,21 @@ extension RoomFlowCoordinator { enum PresentationAction: Hashable { case eventFocus(FocusEvent) case share(ShareExtensionPayload) + case thread(rootEventID: String, focusEventID: String?) var focusedEvent: FocusEvent? { switch self { case .eventFocus(let focusEvent): focusEvent + case .thread(let rootEventID, let focusEventID): + // Since this enum is for the room and not the threaded timeline, + // we will focus the thread root event id, and not the event id itself + // which will be done at the thread presentation level + if focusEventID != nil { + .init(eventID: rootEventID, shouldSetPin: false) + } else { + nil + } default: nil } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index ca4e33df0..9c27ce931 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -132,7 +132,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, .roomDetails, .roomMemberDetails, .userProfile, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, - .share, .transferOwnership: + .share, .transferOwnership, .thread: clearPresentedSheets(animated: animated) // Make sure the presented route is visible. chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) if navigationTabCoordinator.selectedTab != .chats { diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index af6936a80..134a90e6f 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1026,6 +1026,10 @@ internal enum L10n { } /// You are viewing the notification! Click me! internal static var notificationTestPushNotificationContent: String { return L10n.tr("Localizable", "notification_test_push_notification_content") } + /// Thread in %1$@ + internal static func notificationThreadInRoom(_ p1: Any) -> String { + return L10n.tr("Localizable", "notification_thread_in_room", String(describing: p1)) + } /// %1$@: %2$@ internal static func notificationTickerTextDm(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "notification_ticker_text_dm", String(describing: p1), String(describing: p2)) diff --git a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift index c7da10b4f..081ca39eb 100644 --- a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift +++ b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift @@ -25,6 +25,10 @@ extension UNNotificationContent { @objc var pusherNotificationClientIdentifier: String? { userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String } + + @objc var threadRootEventID: String? { + userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String + } } extension UNMutableNotificationContent { @@ -55,6 +59,15 @@ extension UNMutableNotificationContent { } } + override var threadRootEventID: String? { + get { + userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String + } + set { + userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] = newValue + } + } + var unreadCount: Int? { userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int } diff --git a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift index 92999e0fa..30b3369b4 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift @@ -395,7 +395,10 @@ class TimelineTableViewController: UIViewController { guard let self else { return } if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }), let indexPath = dataSource?.indexPath(for: kvPair.key) { - tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) + // Scrolling to the middle created a small bump in the timeline + // Using top, which is bottom in the reversed timeline helps with rendering + // in full long messages and images + tableView.scrollToRow(at: indexPath, at: .top, animated: animated) coordinator.send(viewAction: .scrolledToFocussedItem) // Ensure VoiceOver focus happens after the scroll animation (if any) DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.5 : 0.0)) { diff --git a/ElementX/Sources/Services/Notification/NotificationConstants.swift b/ElementX/Sources/Services/Notification/NotificationConstants.swift index 9359b9920..6615e9a37 100644 --- a/ElementX/Sources/Services/Notification/NotificationConstants.swift +++ b/ElementX/Sources/Services/Notification/NotificationConstants.swift @@ -12,6 +12,7 @@ enum NotificationConstants { enum UserInfoKey { static let roomIdentifier = "room_id" static let eventIdentifier = "event_id" + static let threadRootEventIdentifier = "thread_root_event_id" static let unreadCount = "unread_count" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let receiverIdentifier = "receiver_id" diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift index 3059a4b00..a8ea05c80 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift @@ -77,6 +77,10 @@ struct NotificationItemProxy: NotificationItemProxyProtocol { } return nil } + + var threadRootEventID: String? { + notificationItem.threadId + } } struct EmptyNotificationItemProxy: NotificationItemProxyProtocol { @@ -109,4 +113,6 @@ struct EmptyNotificationItemProxy: NotificationItemProxyProtocol { var roomJoinedMembers: Int { 0 } var hasMention: Bool { false } + + var threadRootEventID: String? { nil } } diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxyProtocol.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxyProtocol.swift index 894bc7cfa..c82a108c7 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxyProtocol.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxyProtocol.swift @@ -36,6 +36,8 @@ protocol NotificationItemProxyProtocol { var isNoisy: Bool { get } var hasMention: Bool { get } + + var threadRootEventID: String? { get } } extension NotificationItemProxyProtocol { diff --git a/NSE/Sources/NSEUserSession.swift b/NSE/Sources/NSEUserSession.swift index 6839ddbae..0f0b1a1a9 100644 --- a/NSE/Sources/NSEUserSession.swift +++ b/NSE/Sources/NSEUserSession.swift @@ -11,6 +11,7 @@ import MatrixRustSDK final class NSEUserSession { let sessionDirectories: SessionDirectories + let appSettings: CommonSettingsProtocol private let baseClient: Client private let notificationClient: NotificationClient @@ -49,6 +50,7 @@ final class NSEUserSession { appSettings: CommonSettingsProtocol) async throws { sessionDirectories = credentials.restorationToken.sessionDirectories userID = credentials.userID + self.appSettings = appSettings let homeserverURL = credentials.restorationToken.session.homeserverUrl let clientBuilder = ClientBuilder diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index d6a5cec93..5de92882c 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -28,6 +28,7 @@ struct NotificationContentBuilder { mediaProvider: MediaProviderProtocol) async { notificationContent.receiverID = notificationItem.receiverID notificationContent.roomID = notificationItem.roomID + notificationContent.threadRootEventID = notificationItem.threadRootEventID switch notificationItem.event { case .timeline(let event): @@ -36,9 +37,16 @@ struct NotificationContentBuilder { notificationContent.eventID = nil } - // So that the UI groups notification that are received for the same room but also for the same user + // So that the UI groups notification that are received for the same room/thread but also for the same user + let threadIdentifier = if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { + // If a threaded message we group notifications also by thread root id + "\(notificationItem.receiverID)\(notificationItem.roomID)\(threadRootEventID)" + } else { + // otherwise only by room and receiver id + "\(notificationItem.receiverID)\(notificationItem.roomID)" + } // Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped - notificationContent.threadIdentifier = "\(notificationItem.receiverID)\(notificationItem.roomID)".replacingOccurrences(of: "@", with: "") + notificationContent.threadIdentifier = threadIdentifier.replacingOccurrences(of: "@", with: "") MXLog.info("isNoisy: \(notificationItem.isNoisy)") notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil @@ -133,10 +141,23 @@ struct NotificationContentBuilder { private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon { if notificationItem.isDM { - return NotificationIcon(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil) + if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { + .init(mediaSource: notificationItem.senderAvatarMediaSource, + groupInfo: .init(name: L10n.commonThread, + id: "\(notificationItem.roomID)\(threadRootEventID)")) + } else { + .init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil) + } } else { - return NotificationIcon(mediaSource: notificationItem.roomAvatarMediaSource, - groupInfo: .init(name: notificationItem.roomDisplayName, id: notificationItem.roomID)) + if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID { + .init(mediaSource: notificationItem.roomAvatarMediaSource, + groupInfo: .init(name: L10n.notificationThreadInRoom(notificationItem.roomDisplayName), + id: "\(notificationItem.roomID)\(threadRootEventID)")) + } else { + .init(mediaSource: notificationItem.roomAvatarMediaSource, + groupInfo: .init(name: notificationItem.roomDisplayName, + id: notificationItem.roomID)) + } } }