mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Display members of a space (#4629)
* Present members of a space * present the members modally from the space * Implemented a room members flow coordinator to make such flow more modular and reusable this is required since we will need to reuse this module also in the space settings, and later we could also replace it in the RoomFlowCoordinator. * the implementation to support at least the SpaceFlowCoordinator is done a follow UP should do the refactor. * remove modal usage from the flow, we want to always be a navigation flow * Improved and implemented the room navigation in the members flow coordinator * pr suggestions and refactored the start chat flow and the invite screen * updated copies for managing room members * Update ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> --------- Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
@@ -1354,7 +1354,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
selectedUsers: .init(selectedUsersSubject),
|
||||
roomType: .room(roomProxy: roomProxy),
|
||||
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
appSettings: flowParameters.appSettings)
|
||||
|
||||
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -1363,22 +1364,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .cancel:
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .proceed:
|
||||
break
|
||||
case .invite(let users):
|
||||
self.inviteUsers(users, in: roomProxy)
|
||||
case .toggleUser(let user):
|
||||
var selectedUsers = selectedUsersSubject.value
|
||||
|
||||
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
|
||||
selectedUsers.remove(at: index)
|
||||
} else {
|
||||
selectedUsers.append(user)
|
||||
}
|
||||
|
||||
selectedUsersSubject.send(selectedUsers)
|
||||
fatalError("Not handled in this flow.")
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -1388,42 +1377,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func inviteUsers(_ users: [String], in room: JoinedRoomProxyProtocol) {
|
||||
if flowParameters.appSettings.enableKeyShareOnInvite {
|
||||
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing,
|
||||
message: L10n.screenRoomDetailsInvitePeopleDontClose)
|
||||
} else {
|
||||
showLoadingIndicator()
|
||||
}
|
||||
|
||||
Task {
|
||||
defer {
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
let result: Result<Void, RoomProxyError> = await withTaskGroup(of: Result<Void, RoomProxyError>.self) { group in
|
||||
for user in users {
|
||||
group.addTask {
|
||||
await room.invite(userID: user)
|
||||
}
|
||||
}
|
||||
|
||||
return await group.first { inviteResult in
|
||||
inviteResult.isFailure
|
||||
} ?? .success(())
|
||||
}
|
||||
|
||||
guard case .failure = result else {
|
||||
return
|
||||
}
|
||||
|
||||
flowParameters.userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.commonUnableToInviteTitle,
|
||||
message: L10n.commonUnableToInviteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRolesAndPermissionsScreen() {
|
||||
let parameters = RoomRolesAndPermissionsFlowCoordinatorParameters(roomProxy: roomProxy,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftState
|
||||
import SwiftUI
|
||||
|
||||
enum RoomMembersFlowCoordinatorAction {
|
||||
case finished
|
||||
case presentCallScreen(roomProxy: JoinedRoomProxyProtocol)
|
||||
case verifyUser(userID: String)
|
||||
}
|
||||
|
||||
enum RoomMembersFlowCoordinatorEntryPoint: Equatable {
|
||||
/// To be used in a room when a member name is tapped
|
||||
case roomMember(userID: String)
|
||||
/// To be used in the context of room details, space details etc.
|
||||
case roomMembersList
|
||||
}
|
||||
|
||||
final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
|
||||
indirect enum State: StateType {
|
||||
/// The state machine hasn't started.
|
||||
case initial
|
||||
/// The room members list
|
||||
case roomMembersList
|
||||
/// The details for a member of the room
|
||||
case roomMemberDetails(userID: String, previousState: State)
|
||||
/// In case the details won't load because the user has left the room we load the profile
|
||||
case userProfile(userID: String, previousState: State)
|
||||
/// The invite users screen
|
||||
case inviteUsersScreen
|
||||
/// A room flow has been started
|
||||
case roomFlow(roomID: String, previousState: State)
|
||||
}
|
||||
|
||||
enum Event: EventType {
|
||||
case start
|
||||
|
||||
case presentRoomMembersList
|
||||
|
||||
case presentRoomMemberDetails(userID: String)
|
||||
case dismissedRoomMemberDetails
|
||||
|
||||
case presentInviteUsersScreen
|
||||
case dismissedInviteUsersScreen
|
||||
|
||||
case presentUserProfile(userID: String)
|
||||
case dismissedUserProfile
|
||||
|
||||
case startRoomFlow(roomID: String)
|
||||
case stopRoomFlow
|
||||
}
|
||||
|
||||
private let entryPoint: RoomMembersFlowCoordinatorEntryPoint
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let flowParameters: CommonFlowParameters
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomMembersFlowCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<RoomMembersFlowCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var roomFlowCoordinator: RoomFlowCoordinator?
|
||||
|
||||
init(entryPoint: RoomMembersFlowCoordinatorEntryPoint,
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
navigationStackCoordinator: NavigationStackCoordinator,
|
||||
flowParameters: CommonFlowParameters) {
|
||||
self.entryPoint = entryPoint
|
||||
self.roomProxy = roomProxy
|
||||
self.flowParameters = flowParameters
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
|
||||
stateMachine = .init(state: .initial)
|
||||
configureStateMachine()
|
||||
}
|
||||
|
||||
func start() {
|
||||
switch entryPoint {
|
||||
case .roomMember(let userID):
|
||||
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID))
|
||||
case .roomMembersList:
|
||||
stateMachine.tryEvent(.presentRoomMembersList)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
fatalError("Unavailable")
|
||||
}
|
||||
|
||||
func clearRoute(animated: Bool) {
|
||||
if stateMachine.state == .inviteUsersScreen {
|
||||
navigationStackCoordinator.setSheetCoordinator(nil, animated: animated)
|
||||
} else if let roomFlowCoordinator {
|
||||
roomFlowCoordinator.clearRoute(animated: animated)
|
||||
}
|
||||
// We don't support dismissing a sub flow by itself, only the entire chain.
|
||||
// The presenter flow will take care of dismissing it
|
||||
actionsSubject.send(.finished)
|
||||
}
|
||||
|
||||
private func configureStateMachine() {
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
switch (fromState, event) {
|
||||
case (.initial, .presentRoomMembersList):
|
||||
return .roomMembersList
|
||||
case (.initial, .presentRoomMemberDetails(let userID)):
|
||||
// previous state doesn't matter in this csase
|
||||
return .roomMemberDetails(userID: userID, previousState: fromState)
|
||||
|
||||
case (.roomMembersList, .presentRoomMemberDetails(let userID)):
|
||||
return .roomMemberDetails(userID: userID, previousState: fromState)
|
||||
case (.roomMemberDetails, .dismissedRoomMemberDetails):
|
||||
return .roomMembersList
|
||||
|
||||
case (.roomMembersList, .presentInviteUsersScreen):
|
||||
return .inviteUsersScreen
|
||||
case (.inviteUsersScreen, .dismissedInviteUsersScreen):
|
||||
return .roomMembersList
|
||||
|
||||
case (.roomMemberDetails(_, let previousState), .presentUserProfile(let userID)):
|
||||
return .userProfile(userID: userID, previousState: previousState)
|
||||
case (.userProfile(_, let previousState), .dismissedUserProfile):
|
||||
return previousState
|
||||
|
||||
case (_, .startRoomFlow(let roomID)):
|
||||
return .roomFlow(roomID: roomID, previousState: fromState)
|
||||
case (.roomFlow(_, let previousState), .stopRoomFlow):
|
||||
return previousState
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
|
||||
guard let self else { return }
|
||||
switch (context.fromState, context.event, context.toState) {
|
||||
case (.initial, .presentRoomMembersList, .roomMembersList):
|
||||
presentRoomMembersList()
|
||||
case (.initial, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)):
|
||||
presentRoomMemberDetails(userID: userID)
|
||||
|
||||
case (.roomMembersList, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)):
|
||||
presentRoomMemberDetails(userID: userID)
|
||||
case (.roomMemberDetails, .dismissedRoomMemberDetails, .roomMembersList):
|
||||
break
|
||||
|
||||
case (.roomMembersList, .presentInviteUsersScreen, .inviteUsersScreen):
|
||||
presentInviteUsersScreen()
|
||||
case (.inviteUsersScreen, .dismissedInviteUsersScreen, .roomMembersList):
|
||||
break
|
||||
|
||||
case (.roomMemberDetails, .presentUserProfile, .userProfile(let userID, _)):
|
||||
replaceRoomMemberDetailsWithUserProfile(userID: userID)
|
||||
case (.userProfile, .dismissedUserProfile, _):
|
||||
break
|
||||
|
||||
case (_, .startRoomFlow(let roomID), .roomFlow):
|
||||
startRoomFlow(roomID: roomID)
|
||||
case (.roomFlow, .stopRoomFlow, _):
|
||||
roomFlowCoordinator = nil
|
||||
|
||||
default:
|
||||
fatalError("Unhandled transition")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRoomMembersList() {
|
||||
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(userSession: flowParameters.userSession,
|
||||
roomProxy: roomProxy,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .invite:
|
||||
stateMachine.tryEvent(.presentInviteUsersScreen)
|
||||
case .selectedMember(let member):
|
||||
stateMachine.tryEvent(.presentRoomMemberDetails(userID: member.userID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.actionsSubject.send(.finished)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRoomMemberDetails(userID: String) {
|
||||
let params = RoomMemberDetailsScreenCoordinatorParameters(userID: userID,
|
||||
roomProxy: roomProxy,
|
||||
userSession: flowParameters.userSession,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics)
|
||||
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .openUserProfile:
|
||||
stateMachine.tryEvent(.presentUserProfile(userID: userID))
|
||||
case .openDirectChat(let roomID):
|
||||
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
|
||||
case .startCall(let roomProxy):
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
case .verifyUser(let userID):
|
||||
actionsSubject.send(.verifyUser(userID: userID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
guard let self else { return }
|
||||
if entryPoint == .roomMember(userID: userID) {
|
||||
actionsSubject.send(.finished)
|
||||
} else {
|
||||
stateMachine.tryEvent(.dismissedRoomMemberDetails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentInviteUsersScreen() {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
|
||||
selectedUsers: nil,
|
||||
roomType: .room(roomProxy: roomProxy),
|
||||
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
appSettings: flowParameters.appSettings)
|
||||
|
||||
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .proceed:
|
||||
fatalError("Not handled in this flow.")
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedInviteUsersScreen)
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceRoomMemberDetailsWithUserProfile(userID: String) {
|
||||
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
|
||||
isPresentedModally: false,
|
||||
userSession: flowParameters.userSession,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics)
|
||||
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .openDirectChat(let roomID):
|
||||
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
|
||||
case .startCall(let roomProxy):
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
case .dismiss:
|
||||
break // Not supported when pushed.
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Replace the RoomMemberDetailsScreen without any animation.
|
||||
// If this pop and push happens before the previous navigation is completed it might break screen presentation logic
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
self.navigationStackCoordinator.pop(animated: false)
|
||||
self.navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedUserProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRoomFlow(roomID: String) {
|
||||
let coordinator = RoomFlowCoordinator(roomID: roomID,
|
||||
isChildFlow: true,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
flowParameters: flowParameters)
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .presentCallScreen(let roomProxy):
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
case .verifyUser(let userID):
|
||||
actionsSubject.send(.verifyUser(userID: userID))
|
||||
case .continueWithSpaceFlow:
|
||||
fatalError("Will never trigger because only direct chats can be displayed in this flow")
|
||||
case .finished:
|
||||
stateMachine.tryEvent(.stopRoomFlow)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomFlowCoordinator = coordinator
|
||||
coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
private var childSpaceFlowCoordinator: SpaceFlowCoordinator?
|
||||
private var roomFlowCoordinator: RoomFlowCoordinator?
|
||||
private var membersFlowCoordinator: RoomMembersFlowCoordinator?
|
||||
|
||||
indirect enum State: StateType {
|
||||
/// The state machine hasn't started.
|
||||
@@ -53,6 +54,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case presentingChild(childSpaceID: String, previousState: State)
|
||||
/// A room flow is in progress
|
||||
case roomFlow(previousState: State)
|
||||
/// A members flow is in progress
|
||||
case membersFlow
|
||||
|
||||
case leftSpace
|
||||
}
|
||||
@@ -77,6 +80,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
case startRoomFlow(roomID: String)
|
||||
case stopRoomFlow
|
||||
|
||||
case startMembersFlow
|
||||
case stopMembersFlow
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
@@ -133,6 +139,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .roomFlow:
|
||||
roomFlowCoordinator?.clearRoute(animated: animated)
|
||||
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
|
||||
case .membersFlow:
|
||||
membersFlowCoordinator?.clearRoute(animated: animated)
|
||||
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +202,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
selectedSpaceRoomSubject.send(nil)
|
||||
}
|
||||
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
guard case .startMembersFlow = event, case .space = fromState else {
|
||||
return nil
|
||||
}
|
||||
return .membersFlow
|
||||
} handler: { [weak self] context in
|
||||
guard let self, let roomProxy = context.userInfo as? JoinedRoomProxyProtocol else {
|
||||
fatalError("The room proxy must always be provided")
|
||||
}
|
||||
Task { await self.startMembersFlow(roomProxy: roomProxy) }
|
||||
}
|
||||
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
guard event == .stopMembersFlow, case .membersFlow = fromState else { return nil }
|
||||
return .space
|
||||
} handler: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
membersFlowCoordinator = nil
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Unexpected transition: \(context)")
|
||||
}
|
||||
@@ -219,6 +248,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
|
||||
case .leftSpace:
|
||||
stateMachine.tryEvent(.leftSpace)
|
||||
case .displayMembers(let roomProxy):
|
||||
stateMachine.tryEvent(.startMembersFlow, userInfo: roomProxy)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -339,4 +370,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
||||
selectedSpaceRoomSubject.send(roomID)
|
||||
}
|
||||
|
||||
private func startMembersFlow(roomProxy: JoinedRoomProxyProtocol) async {
|
||||
let flowCoordinator = RoomMembersFlowCoordinator(entryPoint: .roomMembersList,
|
||||
roomProxy: roomProxy,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
flowParameters: flowParameters)
|
||||
|
||||
flowCoordinator.actions.sink { [weak self] actions in
|
||||
guard let self else { return }
|
||||
switch actions {
|
||||
case .finished:
|
||||
stateMachine.tryEvent(.stopMembersFlow)
|
||||
case .presentCallScreen(let roomProxy):
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
case .verifyUser(let userID):
|
||||
actionsSubject.send(.verifyUser(userID: userID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
membersFlowCoordinator = flowCoordinator
|
||||
flowCoordinator.start()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user