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
This commit is contained in:
Mauro
2025-10-23 15:28:45 +02:00
committed by GitHub
parent 66f0ff0fdf
commit 7a1d4a08fb
16 changed files with 166 additions and 62 deletions
@@ -420,6 +420,7 @@
"leave_room_alert_select_new_owner_title" = "Transfer ownership"; "leave_room_alert_select_new_owner_title" = "Transfer ownership";
"leave_room_alert_subtitle" = "Are you sure that you want to leave the room?"; "leave_room_alert_subtitle" = "Are you sure that you want to leave the room?";
"login_initial_device_name_ios" = "%1$@ iOS"; "login_initial_device_name_ios" = "%1$@ iOS";
"notification_thread_in_room" = "Thread in %1$@";
"notification_channel_call" = "Call"; "notification_channel_call" = "Call";
"notification_channel_listening_for_events" = "Listening for events"; "notification_channel_listening_for_events" = "Listening for events";
"notification_channel_noisy" = "Noisy notifications"; "notification_channel_noisy" = "Noisy notifications";
@@ -359,9 +359,14 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} else { } else {
storedRoomsToAwait = [roomID] storedRoomsToAwait = [roomID]
} }
}
handleAppRoute(.room(roomID: roomID, via: [])) 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: []))
}
} }
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
@@ -51,6 +51,8 @@ enum AppRoute: Hashable {
case share(ShareExtensionPayload) case share(ShareExtensionPayload)
/// The change roles screen of a room with the transfer ownership setting /// The change roles screen of a room with the transfer ownership setting
case transferOwnership(roomID: String) 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. /// Whether or not the route should be handled by the authentication flow.
var isAuthenticationRoute: Bool { var isAuthenticationRoute: Bool {
@@ -130,6 +130,12 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
roomFlowCoordinator?.clearRoute(animated: animated) roomFlowCoordinator?.clearRoute(animated: animated)
case .roomMemberDetails: case .roomMemberDetails:
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) 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): case .event(let eventID, let roomID, let via):
stateMachine.processEvent(.selectRoom(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: .init(animated: animated)) stateMachine.processEvent(.selectRoom(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: .init(animated: animated))
case .eventOnRoomAlias(let eventID, let alias): case .eventOnRoomAlias(let eventID, let alias):
@@ -190,24 +196,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
case (.initial, .start, .roomList): case (.initial, .start, .roomList):
presentHomeScreen() presentHomeScreen()
case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList): case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList):
if case .room(roomID) = detailState, handleSelectRoomTransition(roomID: roomID, via: via, entryPoint: entryPoint, detailState: detailState, animated: animated)
!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.
case(.roomList, .deselectRoom, .roomList): case(.roomList, .deselectRoom, .roomList):
dismissRoomFlow(animated: animated) 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() { private func setupObservers() {
userSession.clientProxy.actionsPublisher userSession.clientProxy.actionsPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -498,6 +509,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.share(payload), animated: animated) coordinator.handleAppRoute(.share(payload), animated: animated)
case .transferOwnership: case .transferOwnership:
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: animated) 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 { Task {
@@ -81,7 +81,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
case .accountProvisioningLink: case .accountProvisioningLink:
break // We always ignore this flow when logged in. break // We always ignore this flow when logged in.
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile, .roomDetails, .roomMemberDetails, .userProfile, .thread,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .genericCallLink, .settings, .share, .transferOwnership: .call, .genericCallLink, .settings, .share, .transferOwnership:
// These routes aren't in this flow so clear the entire stack. // These routes aren't in this flow so clear the entire stack.
@@ -37,6 +37,9 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
case room case room
/// The flow will start by showing the room, focussing on the supplied event ID. /// The flow will start by showing the room, focussing on the supplied event ID.
case eventID(String) 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. /// The flow will start by showing the room's details.
case roomDetails case roomDetails
/// An external media share request /// An external media share request
@@ -154,6 +157,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else { } else {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) 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): case .event(let eventID, let roomID, let via):
Task { Task {
await handleRoomRoute(roomID: roomID, await handleRoomRoute(roomID: roomID,
@@ -162,38 +173,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
animated: animated) animated: animated)
} }
case .childEvent(let eventID, let roomID, let via): case .childEvent(let eventID, let roomID, let via):
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator { handleChildEventRoute(eventID: eventID, roomID: roomID, via: via, animated: animated)
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()
}
}
}
case .share(let payload): case .share(let payload):
guard let roomID = payload.roomID, roomID == self.roomID else { guard let roomID = payload.roomID, roomID == self.roomID else {
fatalError("Navigation route doesn't belong to this room flow.") 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() { private func presentTransferOwnershipScreen() {
let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner, let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner,
roomProxy: roomProxy, roomProxy: roomProxy,
@@ -281,8 +296,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) { switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
case .success(let event): case .success(let event):
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: threadRootEventID, shouldSetPin: false))), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEventID: focusEvent.eventID)), userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEvent.eventID), userInfo: EventUserInfo(animated: false))
} else { } else {
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
} }
@@ -552,6 +566,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text(_, let text)): case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(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: case .none:
break break
} }
@@ -586,6 +603,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .share(.mediaFiles(_, let mediaFiles)): case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .thread(let rootEventID, let focusEventID):
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID))
case .share(.text), .eventFocus: case .share(.text), .eventFocus:
break // These are both handled in the coordinator's init. break // These are both handled in the coordinator's init.
case .none: case .none:
@@ -1564,6 +1583,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.share(payload), animated: true) coordinator.handleAppRoute(.share(payload), animated: true)
case .transferOwnership: case .transferOwnership:
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true) coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true)
case .thread:
fatalError("This entry point is not allowed for child flows")
} }
} }
@@ -25,11 +25,21 @@ extension RoomFlowCoordinator {
enum PresentationAction: Hashable { enum PresentationAction: Hashable {
case eventFocus(FocusEvent) case eventFocus(FocusEvent)
case share(ShareExtensionPayload) case share(ShareExtensionPayload)
case thread(rootEventID: String, focusEventID: String?)
var focusedEvent: FocusEvent? { var focusedEvent: FocusEvent? {
switch self { switch self {
case .eventFocus(let focusEvent): case .eventFocus(let focusEvent):
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: default:
nil nil
} }
@@ -132,7 +132,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile, .roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.share, .transferOwnership: .share, .transferOwnership, .thread:
clearPresentedSheets(animated: animated) // Make sure the presented route is visible. clearPresentedSheets(animated: animated) // Make sure the presented route is visible.
chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
if navigationTabCoordinator.selectedTab != .chats { if navigationTabCoordinator.selectedTab != .chats {
+4
View File
@@ -1026,6 +1026,10 @@ internal enum L10n {
} }
/// You are viewing the notification! Click me! /// You are viewing the notification! Click me!
internal static var notificationTestPushNotificationContent: String { return L10n.tr("Localizable", "notification_test_push_notification_content") } 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$@ /// %1$@: %2$@
internal static func notificationTickerTextDm(_ p1: Any, _ p2: Any) -> String { internal static func notificationTickerTextDm(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "notification_ticker_text_dm", String(describing: p1), String(describing: p2)) return L10n.tr("Localizable", "notification_ticker_text_dm", String(describing: p1), String(describing: p2))
@@ -25,6 +25,10 @@ extension UNNotificationContent {
@objc var pusherNotificationClientIdentifier: String? { @objc var pusherNotificationClientIdentifier: String? {
userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String
} }
@objc var threadRootEventID: String? {
userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String
}
} }
extension UNMutableNotificationContent { 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? { var unreadCount: Int? {
userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int
} }
@@ -395,7 +395,10 @@ class TimelineTableViewController: UIViewController {
guard let self else { return } guard let self else { return }
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }), if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }),
let indexPath = dataSource?.indexPath(for: kvPair.key) { 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) coordinator.send(viewAction: .scrolledToFocussedItem)
// Ensure VoiceOver focus happens after the scroll animation (if any) // Ensure VoiceOver focus happens after the scroll animation (if any)
DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.5 : 0.0)) { DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.5 : 0.0)) {
@@ -12,6 +12,7 @@ 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 threadRootEventIdentifier = "thread_root_event_id"
static let unreadCount = "unread_count" static let unreadCount = "unread_count"
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
static let receiverIdentifier = "receiver_id" static let receiverIdentifier = "receiver_id"
@@ -77,6 +77,10 @@ struct NotificationItemProxy: NotificationItemProxyProtocol {
} }
return nil return nil
} }
var threadRootEventID: String? {
notificationItem.threadId
}
} }
struct EmptyNotificationItemProxy: NotificationItemProxyProtocol { struct EmptyNotificationItemProxy: NotificationItemProxyProtocol {
@@ -109,4 +113,6 @@ struct EmptyNotificationItemProxy: NotificationItemProxyProtocol {
var roomJoinedMembers: Int { 0 } var roomJoinedMembers: Int { 0 }
var hasMention: Bool { false } var hasMention: Bool { false }
var threadRootEventID: String? { nil }
} }
@@ -36,6 +36,8 @@ protocol NotificationItemProxyProtocol {
var isNoisy: Bool { get } var isNoisy: Bool { get }
var hasMention: Bool { get } var hasMention: Bool { get }
var threadRootEventID: String? { get }
} }
extension NotificationItemProxyProtocol { extension NotificationItemProxyProtocol {
+2
View File
@@ -11,6 +11,7 @@ import MatrixRustSDK
final class NSEUserSession { final class NSEUserSession {
let sessionDirectories: SessionDirectories let sessionDirectories: SessionDirectories
let appSettings: CommonSettingsProtocol
private let baseClient: Client private let baseClient: Client
private let notificationClient: NotificationClient private let notificationClient: NotificationClient
@@ -49,6 +50,7 @@ final class NSEUserSession {
appSettings: CommonSettingsProtocol) async throws { appSettings: CommonSettingsProtocol) async throws {
sessionDirectories = credentials.restorationToken.sessionDirectories sessionDirectories = credentials.restorationToken.sessionDirectories
userID = credentials.userID userID = credentials.userID
self.appSettings = appSettings
let homeserverURL = credentials.restorationToken.session.homeserverUrl let homeserverURL = credentials.restorationToken.session.homeserverUrl
let clientBuilder = ClientBuilder let clientBuilder = ClientBuilder
+26 -5
View File
@@ -28,6 +28,7 @@ struct NotificationContentBuilder {
mediaProvider: MediaProviderProtocol) async { mediaProvider: MediaProviderProtocol) async {
notificationContent.receiverID = notificationItem.receiverID notificationContent.receiverID = notificationItem.receiverID
notificationContent.roomID = notificationItem.roomID notificationContent.roomID = notificationItem.roomID
notificationContent.threadRootEventID = notificationItem.threadRootEventID
switch notificationItem.event { switch notificationItem.event {
case .timeline(let event): case .timeline(let event):
@@ -36,9 +37,16 @@ struct NotificationContentBuilder {
notificationContent.eventID = nil 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 // 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)") MXLog.info("isNoisy: \(notificationItem.isNoisy)")
notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
@@ -133,10 +141,23 @@ struct NotificationContentBuilder {
private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon { private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon {
if notificationItem.isDM { 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 { } else {
return NotificationIcon(mediaSource: notificationItem.roomAvatarMediaSource, .init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil)
groupInfo: .init(name: notificationItem.roomDisplayName, id: notificationItem.roomID)) }
} else {
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))
}
} }
} }