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