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 532dd3f4a0
commit dfa5214136
16 changed files with 166 additions and 62 deletions

View File

@@ -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";

View File

@@ -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: []))
}
}
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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)) {

View File

@@ -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"

View File

@@ -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 }
}

View File

@@ -36,6 +36,8 @@ protocol NotificationItemProxyProtocol {
var isNoisy: Bool { get }
var hasMention: Bool { get }
var threadRootEventID: String? { get }
}
extension NotificationItemProxyProtocol {

View File

@@ -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

View File

@@ -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 {
return NotificationIcon(mediaSource: notificationItem.roomAvatarMediaSource,
groupInfo: .init(name: notificationItem.roomDisplayName, id: notificationItem.roomID))
.init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil)
}
} 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))
}
}
}