Membership aware room routes (#2709)

* Remove the invites button when room list invites are enabled

* element-hq/element-x-ios/issues/2632 - Handle room permalinks based on their membership state and show the according room joining screens:
- modify the room flow coordinator to only take a roomID and be able to decide its own room flows (especially for unjoined rooms)
- add support for the new room preview API
- have RoomDirectorySearch go through the same paths
This commit is contained in:
Stefan Ceriu
2024-04-19 17:04:18 +03:00
committed by GitHub
parent 9a172df7fd
commit effe3b6a9b
35 changed files with 698 additions and 247 deletions
@@ -40,7 +40,7 @@ enum RoomFlowCoordinatorAction: Equatable {
// swiftlint:disable file_length
class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let roomProxy: RoomProxyProtocol
private let roomID: String
private let userSession: UserSessionProtocol
private let isChildFlow: Bool
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
@@ -52,6 +52,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let userIndicatorController: UserIndicatorControllerProtocol
private let orientationManager: OrientationManagerProtocol
private var roomProxy: RoomProxyProtocol!
private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator?
// periphery:ignore - used to avoid deallocation
private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator?
// periphery:ignore - used to avoid deallocation
@@ -68,7 +72,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private var timelineController: RoomTimelineControllerProtocol?
init(roomProxy: RoomProxyProtocol,
init(roomID: String,
userSession: UserSessionProtocol,
isChildFlow: Bool,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
@@ -79,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol,
orientationManager: OrientationManagerProtocol) async {
self.roomProxy = roomProxy
self.roomID = roomID
self.userSession = userSession
self.isChildFlow = isChildFlow
self.roomTimelineControllerFactory = roomTimelineControllerFactory
@@ -91,14 +95,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
self.userIndicatorController = userIndicatorController
self.orientationManager = orientationManager
// The SDK needs to handle multiple subscription calls before we can start adding child room flows.
let subscriptionTask = Task { await roomProxy.subscribeForUpdates() }
setupStateMachine()
analytics.signpost.beginRoomFlow(roomProxy.id)
_ = await subscriptionTask.result
analytics.signpost.beginRoomFlow(roomID)
}
// MARK: - FlowCoordinatorProtocol
@@ -110,21 +109,31 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .room(let roomID):
guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") }
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
Task {
await handleRoomRoute(roomID: roomID, animated: animated)
}
case .childRoom(let roomID):
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
} else if roomID != roomProxy.id {
stateMachine.tryEvent(.presentChildRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.startChildFlow(roomID: roomID), userInfo: EventUserInfo(animated: animated))
} else {
MXLog.info("Ignoring presentation of the same room as a child of this one.")
}
case .roomDetails(let roomID):
guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") }
stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated))
case .roomList:
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
Task {
if roomProxy == nil {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
return
}
await storeAndSubscribeToRoomProxy(roomProxy)
}
stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated))
}
case .roomMemberDetails(let userID):
// Always assume this will be presented on the child, external permalinks to a user aren't for a room member.
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
@@ -132,27 +141,68 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
}
case .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
break
}
}
private func handleRoomRoute(roomID: String, animated: Bool) async {
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated))
return
}
switch roomProxy.membership {
case .joined:
await storeAndSubscribeToRoomProxy(roomProxy)
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
default:
stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated))
}
}
func clearRoute(animated: Bool) {
guard stateMachine.state != .initial else {
return
}
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated))
}
// MARK: - Private
private func storeAndSubscribeToRoomProxy(_ roomProxy: RoomProxyProtocol) async {
if let oldRoomProxy = self.roomProxy {
if oldRoomProxy.id != roomProxy.id {
fatalError("Trying to create different room proxies for the same flow coordinator")
}
return
}
guard roomProxy.membership == .joined else {
fatalError("Requesting room details for an unjoined room")
}
self.roomProxy = roomProxy
await roomProxy.subscribeForUpdates()
}
// swiftlint:disable:next function_body_length
private func setupStateMachine() {
stateMachine.addRouteMapping { event, fromState, _ in
switch (fromState, event) {
case (_, .presentJoinRoomScreen):
return .joinRoomScreen
case (_, .dismissJoinRoomScreen):
return .complete
case (_, .presentRoom):
return .room
case (_, .dismissRoom):
case (_, .dismissFlow):
return .complete
case (.initial, .presentRoomDetails):
@@ -255,9 +305,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// Child flow
case (_, .presentChildRoom(let roomID)):
case (_, .startChildFlow(let roomID)):
return .presentingChild(childRoomID: roomID, previousState: fromState)
case (.presentingChild(_, let previousState), .dismissChildRoom):
case (.presentingChild(_, let previousState), .dismissChildFlow):
return previousState
default:
@@ -271,9 +321,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let animated = (context.userInfo as? EventUserInfo)?.animated ?? true
switch (context.fromState, context.event, context.toState) {
case (_, .presentJoinRoomScreen, .joinRoomScreen):
presentJoinRoomScreen(animated: true)
case (_, .dismissJoinRoomScreen, .complete):
dismissFlow(animated: animated)
case (_, .presentRoom, .room):
Task { await self.presentRoom(animated: animated) }
case (_, .dismissRoom, .complete):
case (_, .dismissFlow, .complete):
dismissFlow(animated: animated)
case (.initial, .presentRoomDetails, .roomDetails(let isRoot)),
@@ -381,9 +436,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
rolesAndPermissionsFlowCoordinator = nil
// Child flow
case (_, .presentChildRoom(let roomID), .presentingChild):
Task { await self.presentChildFlow(for: roomID) }
case (.presentingChild, .dismissChildRoom, _):
case (_, .startChildFlow(let roomID), .presentingChild):
Task { await self.startChildFlow(for: roomID) }
case (.presentingChild, .dismissChildFlow, _):
childRoomFlowCoordinator = nil
default:
@@ -479,27 +534,84 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if !isChildFlow {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom)
self?.stateMachine.tryEvent(.dismissFlow)
}
} else {
if joinRoomScreenCoordinator != nil {
navigationStackCoordinator.pop()
}
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissFlow)
}
}
}
private func presentJoinRoomScreen(animated: Bool) {
let coordinator = JoinRoomScreenCoordinator(parameters: .init(roomID: roomID,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController))
joinRoomScreenCoordinator = coordinator
coordinator.actionsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else { return }
switch action {
case .joined:
Task { [weak self] in
guard let self else { return }
if let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) {
await storeAndSubscribeToRoomProxy(roomProxy)
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
analytics.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount))
} else {
stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated))
}
}
case .cancelled:
stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
.store(in: &cancellables)
if !isChildFlow {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
if self?.stateMachine.state == .joinRoomScreen {
self?.stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
} else {
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom)
if self?.stateMachine.state == .joinRoomScreen {
self?.stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
}
}
private func dismissFlow(animated: Bool) {
childRoomFlowCoordinator?.handleAppRoute(.roomList, animated: animated)
childRoomFlowCoordinator?.clearRoute(animated: animated)
if isChildFlow {
// We don't support dismissing a child flow by itself, only the entire chain.
MXLog.info("Leaving navigation clean-up to the parent flow.")
MXLog.info("Leaving the rest of the navigation clean-up to the parent flow.")
if joinRoomScreenCoordinator != nil {
navigationStackCoordinator.pop()
}
} else {
navigationStackCoordinator.popToRoot(animated: false)
navigationStackCoordinator.setRootCoordinator(nil, animated: false)
}
roomProxy.unsubscribeFromUpdates()
roomProxy?.unsubscribeFromUpdates()
timelineController = nil
actionsSubject.send(.finished)
@@ -520,7 +632,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .leftRoom:
stateMachine.tryEvent(.dismissRoom)
stateMachine.tryEvent(.dismissFlow)
case .presentRoomMembersList:
stateMachine.tryEvent(.presentRoomMembersList)
case .presentRoomDetailsEditScreen:
@@ -539,7 +651,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if isRoot {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom) // This seems wrong but it isn't, Maybe the event should be called dismissFlow?
self?.stateMachine.tryEvent(.dismissFlow)
}
} else {
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
@@ -890,7 +1002,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .openUserProfile:
stateMachine.tryEvent(.presentUserProfile(userID: userID))
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
}
}
.store(in: &cancellables)
@@ -913,7 +1025,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
case .dismiss:
break // Not supported when pushed.
}
@@ -983,7 +1095,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// We don't need to worry about passing in the room proxy as timelines are
// cached. The local echo will be visible when fetching the room by its ID.
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
}
private func presentNotificationSettingsScreen() {
@@ -1117,15 +1229,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Child Flow
private func presentChildFlow(for roomID: String) async {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Child flow requested for missing room.")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
stateMachine.tryEvent(.dismissChildRoom)
return
}
let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy,
private func startChildFlow(for roomID: String) async {
let coordinator = await RoomFlowCoordinator(roomID: roomID,
userSession: userSession,
isChildFlow: true,
roomTimelineControllerFactory: roomTimelineControllerFactory,
@@ -1145,7 +1250,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .finished:
stateMachine.tryEvent(.dismissChildRoom)
stateMachine.tryEvent(.dismissChildFlow)
}
}
.store(in: &cancellables)
@@ -1170,6 +1275,7 @@ private extension RoomFlowCoordinator {
indirect enum State: StateType {
case initial
case joinRoomScreen
case room
case roomDetails(isRoot: Bool)
case roomDetailsEditScreen
@@ -1201,8 +1307,11 @@ private extension RoomFlowCoordinator {
}
enum Event: EventType {
case presentJoinRoomScreen
case dismissJoinRoomScreen
case presentRoom
case dismissRoom
case dismissFlow
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case dismissReportContent
@@ -1256,8 +1365,8 @@ private extension RoomFlowCoordinator {
case dismissRolesAndPermissionsScreen
// Child room flow events
case presentChildRoom(roomID: String)
case dismissChildRoom
case startChildFlow(roomID: String)
case dismissChildFlow
}
}
@@ -183,12 +183,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
switch appRoute {
case .room(let roomID):
Task { await self.handleRoomRoute(roomID: roomID, animated: animated) }
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
case .childRoom(let roomID):
if let roomFlowCoordinator {
roomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
} else {
Task { await self.handleRoomRoute(roomID: roomID, animated: animated) }
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
}
case .roomDetails(let roomID):
if stateMachine.state.selectedRoomID == roomID {
@@ -196,12 +196,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true), userInfo: .init(animated: animated))
}
case .roomList, .roomMemberDetails:
self.roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .roomList:
roomFlowCoordinator?.clearRoute(animated: animated)
case .roomMemberDetails:
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .userProfile(let userID):
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
case .genericCallLink(let url):
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
case .oidcCallback:
break
case .settings, .chatBackupSettings:
@@ -210,23 +212,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func handleRoomRoute(roomID: String, animated: Bool) async {
switch await userSession.clientProxy.roomForIdentifier(roomID)?.membership {
case .invited:
if UIDevice.current.isPhone {
roomFlowCoordinator?.clearRoute(animated: animated)
}
stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
case .joined:
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
case .left, .none:
// Do nothing but maybe we should ask design to have some kind of error state
break
}
}
func clearRoute(animated: Bool) {
roomFlowCoordinator?.handleAppRoute(.roomList, animated: animated)
roomFlowCoordinator?.clearRoute(animated: animated)
}
// MARK: - Private
@@ -262,14 +249,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
attemptStartingOnboarding()
case(.roomList, .selectRoom(let roomID, let showingRoomDetails), .roomList):
Task { await self.presentRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) }
Task { await self.startRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) }
case(.roomList, .deselectRoom, .roomList):
tearDownRoomFlow(animated: animated)
dismissRoomFlow(animated: animated)
case (.invitesScreen, .selectRoom, .invitesScreen):
break
case (.invitesScreen, .deselectRoom, .invitesScreen):
tearDownRoomFlow(animated: animated)
dismissRoomFlow(animated: animated)
case (.roomList, .showSettingsScreen, .settingsScreen):
break
@@ -350,9 +337,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .presentRoom(let roomID):
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false))
handleAppRoute(.room(roomID: roomID), animated: true)
case .presentRoomDetails(let roomID):
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true))
handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .roomLeft(let roomID):
if case .roomList(selectedRoomID: let selectedRoomID) = stateMachine.state,
selectedRoomID == roomID {
@@ -430,13 +417,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Room Flow
private func presentRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room ID: \(roomID)")
return
}
let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy,
private func startRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async {
let coordinator = await RoomFlowCoordinator(roomID: roomID,
userSession: userSession,
isChildFlow: false,
roomTimelineControllerFactory: roomTimelineControllerFactory,
@@ -486,7 +468,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func tearDownRoomFlow(animated: Bool) {
private func dismissRoomFlow(animated: Bool) {
// THIS MUST BE CALLED *AFTER* THE FLOW HAS TIDIED UP THE STACK OR IT CAN CAUSE A CRASH.
navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated)
roomFlowCoordinator = nil
@@ -650,7 +632,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .joined(let roomID):
case .select(let roomID):
stateMachine.processEvent(.dismissedRoomDirectorySearchScreen)
handleAppRoute(.room(roomID: roomID), animated: true)
case .dismiss: