mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user