Invite the DM recipient to a new room (#5588)

* handle the details to invite users flow.

* implemented the room creation in the invite screen, and its dismissal + navigaton to the newly created room

* updated preview and unit tests

* pr suggestions
This commit is contained in:
Mauro
2026-05-18 10:08:07 +02:00
committed by GitHub
parent 9ed47c61b7
commit 6c0aaa7a58
39 changed files with 297 additions and 100 deletions
@@ -60,6 +60,13 @@ struct FocusEvent: Hashable {
let shouldSetPin: Bool let shouldSetPin: Bool
} }
enum InviteUsersFlow: Hashable {
/// Invite people into the current room.
case existingRoom
/// Start a new room with the given invitee pre-selected and mandatory.
case newRoom(mandatoryInvitee: UserProfileProxy)
}
// swiftlint:disable:next type_body_length // swiftlint:disable:next type_body_length
class RoomFlowCoordinator: FlowCoordinatorProtocol { class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let roomID: String private let roomID: String
@@ -537,8 +544,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated) presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated)
case (_, .presentInviteUsersScreen, .inviteUsersScreen): case (_, .presentInviteUsersScreen(let flow), .inviteUsersScreen):
presentInviteUsersScreen() presentInviteUsersScreen(flow: flow)
case (_, .presentTransferOwnershipScreen, .transferOwnershipScreen): case (_, .presentTransferOwnershipScreen, .transferOwnershipScreen):
presentTransferOwnershipScreen() presentTransferOwnershipScreen()
@@ -955,7 +962,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentNotificationSettingsScreen: case .presentNotificationSettingsScreen:
stateMachine.tryEvent(.presentNotificationSettingsScreen) stateMachine.tryEvent(.presentNotificationSettingsScreen)
case .presentInviteUsersScreen: case .presentInviteUsersScreen:
stateMachine.tryEvent(.presentInviteUsersScreen) stateMachine.tryEvent(.presentInviteUsersScreen(flow: .existingRoom))
case .presentInviteToNewRoom(let mandatoryInvitee):
stateMachine.tryEvent(.presentInviteUsersScreen(flow: .newRoom(mandatoryInvitee: mandatoryInvitee)))
case .presentPollsHistory: case .presentPollsHistory:
stateMachine.tryEvent(.presentPollsHistory) stateMachine.tryEvent(.presentPollsHistory)
case .presentRolesAndPermissionsScreen: case .presentRolesAndPermissionsScreen:
@@ -1326,10 +1335,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
} }
private func presentInviteUsersScreen() { private func presentInviteUsersScreen(flow: InviteUsersFlow = .existingRoom) {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let roomType: InviteUsersScreenRoomType = switch flow {
case .existingRoom: .existingRoom(roomProxy: roomProxy)
case .newRoom(let mandatoryInvitee): .draft(mandatoryInvitees: [mandatoryInvitee])
}
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: userSession, let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: userSession,
roomProxy: roomProxy, roomType: roomType,
isSkippable: false, isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy), userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,
@@ -1344,6 +1359,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .dismiss: case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
case .openRoom(let roomID):
guard case .newRoom = flow else { return }
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -65,7 +65,7 @@ extension RoomFlowCoordinator {
case roomDetailsEditScreen case roomDetailsEditScreen
case notificationSettings case notificationSettings
case globalNotificationSettings case globalNotificationSettings
case inviteUsersScreen(previousState: State) case inviteUsersScreen(flow: InviteUsersFlow, previousState: State)
case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State) case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State)
case mediaUploadPreview(mediaURLs: [URL], previousState: State) case mediaUploadPreview(mediaURLs: [URL], previousState: State)
case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State) case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State)
@@ -136,7 +136,7 @@ extension RoomFlowCoordinator {
case presentGlobalNotificationSettingsScreen case presentGlobalNotificationSettingsScreen
case dismissGlobalNotificationSettingsScreen case dismissGlobalNotificationSettingsScreen
case presentInviteUsersScreen case presentInviteUsersScreen(flow: InviteUsersFlow)
case dismissInviteUsersScreen case dismissInviteUsersScreen
case presentMediaUploadPicker(mode: MediaPickerScreenMode) case presentMediaUploadPicker(mode: MediaPickerScreenMode)
@@ -404,9 +404,9 @@ extension RoomFlowCoordinator {
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)): case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState) return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState)
case (_, .presentInviteUsersScreen): case (_, .presentInviteUsersScreen(let flow)):
return .inviteUsersScreen(previousState: fromState) return .inviteUsersScreen(flow: flow, previousState: fromState)
case (.inviteUsersScreen(let previousState), .dismissInviteUsersScreen): case (.inviteUsersScreen(_, let previousState), .dismissInviteUsersScreen):
return previousState return previousState
case (_, .presentTransferOwnershipScreen): case (_, .presentTransferOwnershipScreen):
@@ -261,7 +261,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
private func presentInviteUsersScreen() { private func presentInviteUsersScreen() {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession, let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy, roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: false, isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy), userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,
@@ -276,6 +276,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .dismiss: case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
case .openRoom:
break
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -240,7 +240,7 @@ final class SpaceSettingsFlowCoordinator: FlowCoordinatorProtocol {
case .transferOwnership: case .transferOwnership:
stateMachine.tryEvent(.presentTransferOwnership) stateMachine.tryEvent(.presentTransferOwnership)
case .presentRecipientDetails, .presentNotificationSettingsScreen, .presentReportRoomScreen, case .presentRecipientDetails, .presentNotificationSettingsScreen, .presentReportRoomScreen,
.presentInviteUsersScreen, .presentPollsHistory, .presentCall, .presentInviteUsersScreen, .presentInviteToNewRoom, .presentPollsHistory, .presentCall,
.presentPinnedEventsTimeline, .presentMediaEventsTimeline, .presentKnockingRequestsListScreen: .presentPinnedEventsTimeline, .presentMediaEventsTimeline, .presentKnockingRequestsListScreen:
fatalError("Not handled in the space context") fatalError("Not handled in the space context")
} }
@@ -270,7 +270,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol, spaceRoomListProxy: SpaceRoomListProxyProtocol?) { private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol, spaceRoomListProxy: SpaceRoomListProxyProtocol?) {
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession, let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy, roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: true, isSkippable: true,
userDiscoveryService: userDiscoveryService, userDiscoveryService: userDiscoveryService,
userIndicatorController: flowParameters.userIndicatorController, userIndicatorController: flowParameters.userIndicatorController,
@@ -285,6 +285,8 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
} else { } else {
actionsSubject.send(.finished(.room(id: roomProxy.id))) actionsSubject.send(.finished(.room(id: roomProxy.id)))
} }
case .openRoom:
break
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -3367,8 +3367,8 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled: Bool { var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled: Bool {
return createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount > 0 return createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount > 0
} }
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments: (name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)? var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments: (name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)?
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedInvocations: [(name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)] = [] var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedInvocations: [(name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)] = []
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue: Result<String, ClientProxyError>! var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue: Result<String, ClientProxyError>!
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue: Result<String, ClientProxyError>! { var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue: Result<String, ClientProxyError>! {
@@ -3394,9 +3394,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
} }
} }
} }
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure: ((String, String?, CreateRoomAccessType, Bool, [String], URL?, String?) async -> Result<String, ClientProxyError>)? var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure: ((String?, String?, CreateRoomAccessType, Bool, [String], URL?, String?) async -> Result<String, ClientProxyError>)?
func createRoom(name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?) async -> Result<String, ClientProxyError> { func createRoom(name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount += 1 createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount += 1
createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments = (name: name, topic: topic, accessType: accessType, isSpace: isSpace, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart) createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments = (name: name, topic: topic, accessType: accessType, isSpace: isSpace, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart)
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -11,7 +11,7 @@ import SwiftUI
struct InviteUsersScreenCoordinatorParameters { struct InviteUsersScreenCoordinatorParameters {
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
let roomProxy: JoinedRoomProxyProtocol let roomType: InviteUsersScreenRoomType
let isSkippable: Bool let isSkippable: Bool
let userDiscoveryService: UserDiscoveryServiceProtocol let userDiscoveryService: UserDiscoveryServiceProtocol
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
@@ -20,6 +20,7 @@ struct InviteUsersScreenCoordinatorParameters {
enum InviteUsersScreenCoordinatorAction { enum InviteUsersScreenCoordinatorAction {
case dismiss case dismiss
case openRoom(roomID: String)
} }
final class InviteUsersScreenCoordinator: CoordinatorProtocol { final class InviteUsersScreenCoordinator: CoordinatorProtocol {
@@ -33,7 +34,7 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
init(parameters: InviteUsersScreenCoordinatorParameters) { init(parameters: InviteUsersScreenCoordinatorParameters) {
viewModel = InviteUsersScreenViewModel(userSession: parameters.userSession, viewModel = InviteUsersScreenViewModel(userSession: parameters.userSession,
roomProxy: parameters.roomProxy, roomType: parameters.roomType,
isSkippable: parameters.isSkippable, isSkippable: parameters.isSkippable,
userDiscoveryService: parameters.userDiscoveryService, userDiscoveryService: parameters.userDiscoveryService,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
@@ -46,6 +47,8 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
switch action { switch action {
case .dismiss: case .dismiss:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
case .openRoom(let roomID):
actionsSubject.send(.openRoom(roomID: roomID))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -16,11 +16,12 @@ enum InviteUsersScreenErrorType: Error {
enum InviteUsersScreenViewModelAction { enum InviteUsersScreenViewModelAction {
case dismiss case dismiss
case openRoom(roomID: String)
} }
enum InviteUsersScreenRoomType { enum InviteUsersScreenRoomType {
case draft case draft(mandatoryInvitees: [UserProfileProxy])
case room(roomProxy: JoinedRoomProxyProtocol) case existingRoom(roomProxy: JoinedRoomProxyProtocol)
} }
struct InviteUsersScreenViewState: BindableState { struct InviteUsersScreenViewState: BindableState {
@@ -29,6 +30,7 @@ struct InviteUsersScreenViewState: BindableState {
var usersSection: UserDiscoverySection = .init(type: .suggestions, users: []) var usersSection: UserDiscoverySection = .init(type: .suggestions, users: [])
var selectedUsers: [UserProfileProxy] = [] var selectedUsers: [UserProfileProxy] = []
var mandatoryInvitees: [UserProfileProxy] = []
var membershipState: [String: MembershipState] = .init() var membershipState: [String: MembershipState] = .init()
var usersToConfirm: [UserProfileProxy] = [] var usersToConfirm: [UserProfileProxy] = []
@@ -38,32 +40,29 @@ struct InviteUsersScreenViewState: BindableState {
!isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty !isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty
} }
var hasInvitableSelectedUsers: Bool {
selectedUsers.contains { !isInviteeMandatory($0) }
}
func isUserSelected(_ user: UserProfileProxy) -> Bool { func isUserSelected(_ user: UserProfileProxy) -> Bool {
isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID } isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID }
} }
func isUserDisabled(_ user: UserProfileProxy) -> Bool { func isUserDisabled(_ user: UserProfileProxy) -> Bool {
if isInviteeMandatory(user) { return true }
let membershipState = membershipState(user) let membershipState = membershipState(user)
return membershipState == .invite || membershipState == .join return membershipState == .invite || membershipState == .join
} }
func isInviteeMandatory(_ user: UserProfileProxy) -> Bool {
mandatoryInvitees.contains { $0.userID == user.userID }
}
func membershipState(_ user: UserProfileProxy) -> MembershipState? { func membershipState(_ user: UserProfileProxy) -> MembershipState? {
membershipState[user.userID] membershipState[user.userID]
} }
let isSkippable: Bool let isSkippable: Bool
var actionText: String {
if isSkippable, selectedUsers.isEmpty {
L10n.actionSkip
} else {
L10n.actionInvite
}
}
var isActionDisabled: Bool {
isSkippable ? false : selectedUsers.isEmpty
}
} }
struct InviteUsersScreenViewStateBindings { struct InviteUsersScreenViewStateBindings {
@@ -14,7 +14,7 @@ typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreen
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol { class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
private let roomProxy: JoinedRoomProxyProtocol private let roomType: InviteUsersScreenRoomType
private let userDiscoveryService: UserDiscoveryServiceProtocol private let userDiscoveryService: UserDiscoveryServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
@@ -27,18 +27,21 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
} }
init(userSession: UserSessionProtocol, init(userSession: UserSessionProtocol,
roomProxy: JoinedRoomProxyProtocol, roomType: InviteUsersScreenRoomType,
isSkippable: Bool, isSkippable: Bool,
userDiscoveryService: UserDiscoveryServiceProtocol, userDiscoveryService: UserDiscoveryServiceProtocol,
userIndicatorController: UserIndicatorControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings) { appSettings: AppSettings) {
clientProxy = userSession.clientProxy clientProxy = userSession.clientProxy
self.roomProxy = roomProxy self.roomType = roomType
self.userDiscoveryService = userDiscoveryService self.userDiscoveryService = userDiscoveryService
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.appSettings = appSettings self.appSettings = appSettings
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: [], let mandatoryInvitees: [UserProfileProxy] = if case .draft(let invitees) = roomType { invitees } else { [] }
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: mandatoryInvitees,
mandatoryInvitees: mandatoryInvitees,
isSkippable: isSkippable), isSkippable: isSkippable),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
@@ -61,6 +64,10 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
case .cancel: case .cancel:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
case .proceed: case .proceed:
switch roomType {
case .draft:
createDraftRoom(mandatoryUserIDs: state.selectedUsers.map(\.userID))
case .existingRoom(let roomProxy):
guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden, guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
!state.usersToConfirm.isEmpty, !state.usersToConfirm.isEmpty,
!state.isSkippable else { !state.isSkippable else {
@@ -68,6 +75,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
return return
} }
state.bindings.presentConfirmationDialog = true state.bindings.presentConfirmationDialog = true
}
case .removeUnknownUsers: case .removeUnknownUsers:
state.bindings.presentConfirmationDialog = false state.bindings.presentConfirmationDialog = false
state.selectedUsers.removeAll { user in state.selectedUsers.removeAll { user in
@@ -77,7 +85,9 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
case .confirmUnknownUsers: case .confirmUnknownUsers:
state.bindings.presentConfirmationDialog = false state.bindings.presentConfirmationDialog = false
state.usersToConfirm = [] state.usersToConfirm = []
if case .existingRoom(let roomProxy) = roomType {
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy) inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
}
case .toggleUser(let user): case .toggleUser(let user):
toggleUser(user) toggleUser(user)
} }
@@ -86,6 +96,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
// MARK: - Private // MARK: - Private
private func toggleUser(_ user: UserProfileProxy) { private func toggleUser(_ user: UserProfileProxy) {
guard !state.isInviteeMandatory(user) else { return }
if state.selectedUsers.contains(user) { if state.selectedUsers.contains(user) {
state.selectedUsers.removeAll { $0.userID == user.userID } state.selectedUsers.removeAll { $0.userID == user.userID }
} else { } else {
@@ -105,6 +117,29 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
} }
} }
private func createDraftRoom(mandatoryUserIDs: [String]) {
showLoadingIndicator(title: L10n.commonCreatingRoom)
Task {
defer { hideLoadingIndicator() }
switch await clientProxy.createRoom(name: nil,
topic: nil,
accessType: .private,
isSpace: false,
userIDs: mandatoryUserIDs,
avatarURL: nil,
aliasLocalPart: nil) {
case .success(let roomID):
actionsSubject.send(.openRoom(roomID: roomID))
case .failure:
state.bindings.alertInfo = .init(id: .unknown,
title: L10n.commonError,
message: L10n.screenStartChatErrorStartingChat)
}
}
}
private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) { private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) {
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, message: L10n.screenRoomDetailsInvitePeopleDontClose) showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, message: L10n.screenRoomDetailsInvitePeopleDontClose)
@@ -168,6 +203,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
} }
private func fetchMembersIfNeeded() { private func fetchMembersIfNeeded() {
guard case .existingRoom(let roomProxy) = roomType else { return }
Task { Task {
showLoadingIndicator() showLoadingIndicator()
await roomProxy.updateMembers() await roomProxy.updateMembers()
@@ -61,7 +61,7 @@ struct InviteUsersConfirmationSheetView_Previews: PreviewProvider, TestablePrevi
static func makeViewModel() -> InviteUsersScreenViewModel { static func makeViewModel() -> InviteUsersScreenViewModel {
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))), let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
roomProxy: JoinedRoomProxyMock(.init(members: [])), roomType: .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
isSkippable: true, isSkippable: true,
userDiscoveryService: UserDiscoveryServiceMock(), userDiscoveryService: UserDiscoveryServiceMock(),
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
@@ -110,7 +110,9 @@ struct InviteUsersScreen: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(context.viewState.selectedUsers, id: \.userID) { user in ForEach(context.viewState.selectedUsers, id: \.userID) { user in
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) { InviteUsersScreenSelectedItem(user: user,
mediaProvider: context.mediaProvider,
isLocked: context.viewState.isInviteeMandatory(user)) {
deselect(user) deselect(user)
} }
.frame(width: selectedUserCellWidth) .frame(width: selectedUserCellWidth)
@@ -133,11 +135,18 @@ struct InviteUsersScreen: View {
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button(context.viewState.actionText) { if context.viewState.isSkippable, context.viewState.selectedUsers.isEmpty {
Button(L10n.actionSkip) {
context.send(viewAction: .proceed) context.send(viewAction: .proceed)
} }
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed) .accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
.disabled(context.viewState.isActionDisabled) } else {
ToolbarButton(role: .confirm(title: L10n.actionInvite)) {
context.send(viewAction: .proceed)
}
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
.disabled(!context.viewState.hasInvitableSelectedUsers)
}
} }
} }
@@ -153,6 +162,7 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
static let searchingViewModel = makeViewModel(searchQuery: "Alice") static let searchingViewModel = makeViewModel(searchQuery: "Alice")
static let selectedViewModel = makeViewModel(hasSelection: true) static let selectedViewModel = makeViewModel(hasSelection: true)
static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true) static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true)
static let draftViewModel = makeViewModel(roomType: .draft(mandatoryInvitees: [.mockAlice]), isSkippable: false)
static var previews: some View { static var previews: some View {
ElementNavigationStack { ElementNavigationStack {
@@ -179,9 +189,19 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
InviteUsersScreen(context: confirmSelectedViewModel.context) InviteUsersScreen(context: confirmSelectedViewModel.context)
} }
.previewDisplayName("Confirm Selected") .previewDisplayName("Confirm Selected")
ElementNavigationStack {
InviteUsersScreen(context: draftViewModel.context)
}
.previewDisplayName("Draft (locked invitee)")
.snapshotPreferences(expect: draftViewModel.context.$viewState.map { !$0.mandatoryInvitees.isEmpty })
} }
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false, shouldConfirm: Bool = false) -> InviteUsersScreenViewModel { static func makeViewModel(searchQuery: String? = nil,
hasSelection: Bool = false,
shouldConfirm: Bool = false,
roomType: InviteUsersScreenRoomType? = nil,
isSkippable: Bool = true) -> InviteUsersScreenViewModel {
let clientProxy = ClientProxyMock(.init()) let clientProxy = ClientProxyMock(.init())
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose] clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
@@ -189,8 +209,8 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice]) userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomProxy: JoinedRoomProxyMock(.init(members: [])), roomType: roomType ?? .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
isSkippable: true, isSkippable: isSkippable,
userDiscoveryService: userDiscoveryService, userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings()) appSettings: AppSettings())
@@ -12,6 +12,7 @@ import SwiftUI
struct InviteUsersScreenSelectedItem: View { struct InviteUsersScreenSelectedItem: View {
let user: UserProfileProxy let user: UserProfileProxy
let mediaProvider: MediaProviderProtocol? let mediaProvider: MediaProviderProtocol?
var isLocked = false
let dismissAction: () -> Void let dismissAction: () -> Void
var body: some View { var body: some View {
@@ -25,18 +26,27 @@ struct InviteUsersScreenSelectedItem: View {
.lineLimit(1) .lineLimit(1)
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityAction(named: L10n.actionRemove, dismissAction) .accessibilityActions {
if !isLocked {
Button(L10n.actionRemove, action: dismissAction)
}
}
} }
// MARK: - Private // MARK: - Private
@ViewBuilder
var avatar: some View { var avatar: some View {
LoadableAvatarImage(url: user.avatarURL, let avatarImage = LoadableAvatarImage(url: user.avatarURL,
name: user.displayName, name: user.displayName,
contentID: user.userID, contentID: user.userID,
avatarSize: .user(on: .inviteUsers), avatarSize: .user(on: .inviteUsers),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
.overlayRemoveItemButton(action: dismissAction) if isLocked {
avatarImage
} else {
avatarImage.overlayRemoveItemButton(action: dismissAction)
}
} }
var closeButtonLabel: some View { var closeButtonLabel: some View {
@@ -26,6 +26,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentRoomDetailsEditScreen case presentRoomDetailsEditScreen
case presentNotificationSettingsScreen case presentNotificationSettingsScreen
case presentInviteUsersScreen case presentInviteUsersScreen
case presentInviteToNewRoom(invitee: UserProfileProxy)
case presentPollsHistory case presentPollsHistory
case presentRolesAndPermissionsScreen case presentRolesAndPermissionsScreen
case presentCall(isVoiceCall: Bool) case presentCall(isVoiceCall: Bool)
@@ -71,6 +72,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomMembersList) actionsSubject.send(.presentRoomMembersList)
case .requestInvitePeoplePresentation: case .requestInvitePeoplePresentation:
actionsSubject.send(.presentInviteUsersScreen) actionsSubject.send(.presentInviteUsersScreen)
case .requestInviteToNewRoomPresentation(let invitee):
actionsSubject.send(.presentInviteToNewRoom(invitee: invitee))
case .leftRoom: case .leftRoom:
actionsSubject.send(.leftRoom) actionsSubject.send(.leftRoom)
case .requestEditDetailsPresentation: case .requestEditDetailsPresentation:
@@ -18,6 +18,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
case requestMemberDetailsPresentation case requestMemberDetailsPresentation
case requestRecipientDetailsPresentation(userID: String) case requestRecipientDetailsPresentation(userID: String)
case requestInvitePeoplePresentation case requestInvitePeoplePresentation
case requestInviteToNewRoomPresentation(selectedInvitee: UserProfileProxy)
case leftRoom case leftRoom
case requestEditDetailsPresentation case requestEditDetailsPresentation
case requestPollsHistoryPresentation case requestPollsHistoryPresentation
@@ -98,6 +99,7 @@ struct RoomDetailsScreenViewState: BindableState {
} }
shortcuts.append(.videoCall) shortcuts.append(.videoCall)
} }
// The invite flow is different for DMs
if dmRecipientInfo == nil, canInviteUsers { if dmRecipientInfo == nil, canInviteUsers {
shortcuts.append(.invite) shortcuts.append(.invite)
} }
@@ -110,7 +110,11 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
case .processTapPeople: case .processTapPeople:
actionsSubject.send(.requestMemberDetailsPresentation) actionsSubject.send(.requestMemberDetailsPresentation)
case .processTapInvite: case .processTapInvite:
if let dmRecipient = state.dmRecipientInfo {
actionsSubject.send(.requestInviteToNewRoomPresentation(selectedInvitee: .init(member: dmRecipient.member)))
} else {
actionsSubject.send(.requestInvitePeoplePresentation) actionsSubject.send(.requestInvitePeoplePresentation)
}
case .processTapLeave: case .processTapLeave:
processTapToLeave() processTapToLeave()
case .confirmLeave: case .confirmLeave:
@@ -22,6 +22,11 @@ struct RoomDetailsScreen: View {
aboutSection aboutSection
// The invitation flow is different for DMs
if context.viewState.dmRecipientInfo != nil {
inviteToNewRoomSection
}
configurationSection configurationSection
if context.viewState.dmRecipientInfo == nil { if context.viewState.dmRecipientInfo == nil {
@@ -165,6 +170,13 @@ struct RoomDetailsScreen: View {
} }
} }
private var inviteToNewRoomSection: some View {
ListRow(label: .default(title: L10n.actionInvite, icon: \.userAdd),
kind: .navigationLink {
context.send(viewAction: .processTapInvite)
})
}
private var configurationSection: some View { private var configurationSection: some View {
Section { Section {
ListRow(label: .default(title: L10n.screenRoomDetailsNotificationTitle, icon: \.notifications), ListRow(label: .default(title: L10n.screenRoomDetailsNotificationTitle, icon: \.notifications),
@@ -516,7 +516,7 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
func createRoom(name: String, func createRoom(name: String?,
topic: String?, topic: String?,
accessType: CreateRoomAccessType, accessType: CreateRoomAccessType,
isSpace: Bool, isSpace: Bool,
@@ -183,7 +183,7 @@ protocol ClientProxyProtocol: AnyObject {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError> func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
func createRoom(name: String, func createRoom(name: String?,
topic: String?, topic: String?,
accessType: CreateRoomAccessType, accessType: CreateRoomAccessType,
isSpace: Bool, isSpace: Bool,
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9413c3be57bf3882bff847d80d76616413d2c16a63ba316a760548fff2b6386
size 151997
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e70eaef9bc01eccd462be75ac69e1d556784596a0287da9a8f3f7e10e4e58b0
size 156413
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5338f0606064bc4c3c098c2458f1923db59f206c059cfa11bdbc387d42b3af3a
size 99061
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c9456d7cf947a55063eae44ea955c34fd3af41663ffffbe1f72ba47f791cab0
size 102079
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2efff4b459ba93bc6c8290a3ff46a26a684b2c6abe2812c8a4667a86b060711 oid sha256:5a87b4c36df926a3cde0d0a15022e5b5c0c0e545c97974f916b506b4862fc5ab
size 151210 size 149551
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1a6fa5cea94e92da8768cb87d2d157b9552ec3624fbc4f1241c0c212e93f2d98 oid sha256:d450d451e9ca7779f4f186f2eabf8a23cef9b2dd861e581ed1c61d0dbbb06499
size 155938 size 153569
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:34bbaf989ac4635edb87a1e2855c45af93eba45aae198a56173186014b79ddb3 oid sha256:c793431f951a5ad023ab9b358658a5d75244e292cf56c0fc4a808b84df654e7a
size 98426 size 97084
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9cebab4fae25f0ea54e0583563719429d06d52949d7847458a98dec6c0ce746d oid sha256:68c222b7a50badb09b9f2cbdcc6010edac0a7a2727ed9f2f98713e2874ffa214
size 101873 size 100304
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 244787 size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8 oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 253574 size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3 oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 181065 size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7 oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185625 size 185369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 244787 size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8 oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 253574 size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3 oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 181065 size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7 oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185625 size 185369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 244787 size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8 oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 253574 size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3 oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 181065 size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7 oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185625 size 185369
@@ -87,6 +87,8 @@ final class InviteUsersScreenViewModelTests {
switch action { switch action {
case .dismiss: case .dismiss:
return true return true
case .openRoom:
return false
} }
} }
@@ -122,6 +124,8 @@ final class InviteUsersScreenViewModelTests {
switch action { switch action {
case .dismiss: case .dismiss:
return true return true
case .openRoom:
return false
} }
} }
@@ -159,6 +163,74 @@ final class InviteUsersScreenViewModelTests {
try await deferredState.fulfill() try await deferredState.fulfill()
} }
// MARK: - Draft (new room)
@Test
func createsNewRoomInDraftMode() async throws {
userDiscoveryService = UserDiscoveryServiceMock()
userDiscoveryService.searchProfilesWithReturnValue = .success([])
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
let newRoomID = "!newroom:example.com"
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success(newRoomID)
viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomType: .draft(mandatoryInvitees: [.mockAlice]),
isSkippable: false,
userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings())
// The locked invitee starts pre-selected and locked.
#expect(context.viewState.selectedUsers.map(\.userID) == [UserProfileProxy.mockAlice.userID])
#expect(context.viewState.isInviteeMandatory(.mockAlice))
// The proceed button is disabled while the only selected user is the locked invitee.
#expect(!context.viewState.hasInvitableSelectedUsers)
// Selecting a non-locked user enables the proceed button.
var deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
#expect(context.viewState.hasInvitableSelectedUsers)
// Deselecting the non-locked user disables the proceed button again.
deferredState = deferFulfillment(viewModel.context.$viewState) { state in
!state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
#expect(!context.viewState.hasInvitableSelectedUsers)
// Re-select the non-locked user before proceeding.
deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
let deferredAction = deferFulfillment(viewModel.actions) { action in
if case .openRoom(let roomID) = action, roomID == newRoomID {
return true
}
return false
}
context.send(viewAction: .proceed)
try await deferredAction.fulfill()
let args = try #require(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments)
#expect(args.name == nil)
#expect(args.topic == nil)
#expect(args.accessType == .private)
#expect(args.isSpace == false)
#expect(args.userIDs == [UserProfileProxy.mockAlice.userID, UserProfileProxy.mockBob.userID])
#expect(args.avatarURL == nil)
#expect(args.aliasLocalPart == nil)
}
// MARK: - Helpers // MARK: - Helpers
private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) { private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) {
@@ -168,7 +240,7 @@ final class InviteUsersScreenViewModelTests {
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com")) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomProxy: roomProxy, roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: isSkippable, isSkippable: isSkippable,
userDiscoveryService: userDiscoveryService, userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),