mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -60,6 +60,13 @@ struct FocusEvent: Hashable {
|
||||
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
|
||||
class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let roomID: String
|
||||
@@ -537,8 +544,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated)
|
||||
|
||||
case (_, .presentInviteUsersScreen, .inviteUsersScreen):
|
||||
presentInviteUsersScreen()
|
||||
case (_, .presentInviteUsersScreen(let flow), .inviteUsersScreen):
|
||||
presentInviteUsersScreen(flow: flow)
|
||||
|
||||
case (_, .presentTransferOwnershipScreen, .transferOwnershipScreen):
|
||||
presentTransferOwnershipScreen()
|
||||
@@ -955,7 +962,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .presentNotificationSettingsScreen:
|
||||
stateMachine.tryEvent(.presentNotificationSettingsScreen)
|
||||
case .presentInviteUsersScreen:
|
||||
stateMachine.tryEvent(.presentInviteUsersScreen)
|
||||
stateMachine.tryEvent(.presentInviteUsersScreen(flow: .existingRoom))
|
||||
case .presentInviteToNewRoom(let mandatoryInvitee):
|
||||
stateMachine.tryEvent(.presentInviteUsersScreen(flow: .newRoom(mandatoryInvitee: mandatoryInvitee)))
|
||||
case .presentPollsHistory:
|
||||
stateMachine.tryEvent(.presentPollsHistory)
|
||||
case .presentRolesAndPermissionsScreen:
|
||||
@@ -1326,10 +1335,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentInviteUsersScreen() {
|
||||
private func presentInviteUsersScreen(flow: InviteUsersFlow = .existingRoom) {
|
||||
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,
|
||||
roomProxy: roomProxy,
|
||||
roomType: roomType,
|
||||
isSkippable: false,
|
||||
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
@@ -1344,6 +1359,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
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)
|
||||
|
||||
@@ -65,7 +65,7 @@ extension RoomFlowCoordinator {
|
||||
case roomDetailsEditScreen
|
||||
case notificationSettings
|
||||
case globalNotificationSettings
|
||||
case inviteUsersScreen(previousState: State)
|
||||
case inviteUsersScreen(flow: InviteUsersFlow, previousState: State)
|
||||
case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State)
|
||||
case mediaUploadPreview(mediaURLs: [URL], previousState: State)
|
||||
case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State)
|
||||
@@ -136,7 +136,7 @@ extension RoomFlowCoordinator {
|
||||
case presentGlobalNotificationSettingsScreen
|
||||
case dismissGlobalNotificationSettingsScreen
|
||||
|
||||
case presentInviteUsersScreen
|
||||
case presentInviteUsersScreen(flow: InviteUsersFlow)
|
||||
case dismissInviteUsersScreen
|
||||
|
||||
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
|
||||
@@ -404,9 +404,9 @@ extension RoomFlowCoordinator {
|
||||
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)):
|
||||
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState)
|
||||
|
||||
case (_, .presentInviteUsersScreen):
|
||||
return .inviteUsersScreen(previousState: fromState)
|
||||
case (.inviteUsersScreen(let previousState), .dismissInviteUsersScreen):
|
||||
case (_, .presentInviteUsersScreen(let flow)):
|
||||
return .inviteUsersScreen(flow: flow, previousState: fromState)
|
||||
case (.inviteUsersScreen(_, let previousState), .dismissInviteUsersScreen):
|
||||
return previousState
|
||||
|
||||
case (_, .presentTransferOwnershipScreen):
|
||||
|
||||
@@ -261,7 +261,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private func presentInviteUsersScreen() {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
|
||||
roomProxy: roomProxy,
|
||||
roomType: .existingRoom(roomProxy: roomProxy),
|
||||
isSkippable: false,
|
||||
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
@@ -276,6 +276,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .openRoom:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -240,7 +240,7 @@ final class SpaceSettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .transferOwnership:
|
||||
stateMachine.tryEvent(.presentTransferOwnership)
|
||||
case .presentRecipientDetails, .presentNotificationSettingsScreen, .presentReportRoomScreen,
|
||||
.presentInviteUsersScreen, .presentPollsHistory, .presentCall,
|
||||
.presentInviteUsersScreen, .presentInviteToNewRoom, .presentPollsHistory, .presentCall,
|
||||
.presentPinnedEventsTimeline, .presentMediaEventsTimeline, .presentKnockingRequestsListScreen:
|
||||
fatalError("Not handled in the space context")
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol, spaceRoomListProxy: SpaceRoomListProxyProtocol?) {
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
|
||||
roomProxy: roomProxy,
|
||||
roomType: .existingRoom(roomProxy: roomProxy),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
@@ -285,6 +285,8 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
|
||||
} else {
|
||||
actionsSubject.send(.finished(.room(id: roomProxy.id)))
|
||||
}
|
||||
case .openRoom:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -3367,8 +3367,8 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled: Bool {
|
||||
return createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount > 0
|
||||
}
|
||||
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 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 createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue: 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
|
||||
createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments = (name: name, topic: topic, accessType: accessType, isSpace: isSpace, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart)
|
||||
DispatchQueue.main.async {
|
||||
|
||||
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
|
||||
struct InviteUsersScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let roomProxy: JoinedRoomProxyProtocol
|
||||
let roomType: InviteUsersScreenRoomType
|
||||
let isSkippable: Bool
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
@@ -20,6 +20,7 @@ struct InviteUsersScreenCoordinatorParameters {
|
||||
|
||||
enum InviteUsersScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case openRoom(roomID: String)
|
||||
}
|
||||
|
||||
final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -33,7 +34,7 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: InviteUsersScreenCoordinatorParameters) {
|
||||
viewModel = InviteUsersScreenViewModel(userSession: parameters.userSession,
|
||||
roomProxy: parameters.roomProxy,
|
||||
roomType: parameters.roomType,
|
||||
isSkippable: parameters.isSkippable,
|
||||
userDiscoveryService: parameters.userDiscoveryService,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
@@ -46,6 +47,8 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .openRoom(let roomID):
|
||||
actionsSubject.send(.openRoom(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -16,11 +16,12 @@ enum InviteUsersScreenErrorType: Error {
|
||||
|
||||
enum InviteUsersScreenViewModelAction {
|
||||
case dismiss
|
||||
case openRoom(roomID: String)
|
||||
}
|
||||
|
||||
enum InviteUsersScreenRoomType {
|
||||
case draft
|
||||
case room(roomProxy: JoinedRoomProxyProtocol)
|
||||
case draft(mandatoryInvitees: [UserProfileProxy])
|
||||
case existingRoom(roomProxy: JoinedRoomProxyProtocol)
|
||||
}
|
||||
|
||||
struct InviteUsersScreenViewState: BindableState {
|
||||
@@ -29,6 +30,7 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
var usersSection: UserDiscoverySection = .init(type: .suggestions, users: [])
|
||||
|
||||
var selectedUsers: [UserProfileProxy] = []
|
||||
var mandatoryInvitees: [UserProfileProxy] = []
|
||||
var membershipState: [String: MembershipState] = .init()
|
||||
var usersToConfirm: [UserProfileProxy] = []
|
||||
|
||||
@@ -38,32 +40,29 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
!isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty
|
||||
}
|
||||
|
||||
var hasInvitableSelectedUsers: Bool {
|
||||
selectedUsers.contains { !isInviteeMandatory($0) }
|
||||
}
|
||||
|
||||
func isUserSelected(_ user: UserProfileProxy) -> Bool {
|
||||
isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID }
|
||||
}
|
||||
|
||||
func isUserDisabled(_ user: UserProfileProxy) -> Bool {
|
||||
if isInviteeMandatory(user) { return true }
|
||||
let membershipState = membershipState(user)
|
||||
return membershipState == .invite || membershipState == .join
|
||||
}
|
||||
|
||||
func isInviteeMandatory(_ user: UserProfileProxy) -> Bool {
|
||||
mandatoryInvitees.contains { $0.userID == user.userID }
|
||||
}
|
||||
|
||||
func membershipState(_ user: UserProfileProxy) -> MembershipState? {
|
||||
membershipState[user.userID]
|
||||
}
|
||||
|
||||
let isSkippable: Bool
|
||||
|
||||
var actionText: String {
|
||||
if isSkippable, selectedUsers.isEmpty {
|
||||
L10n.actionSkip
|
||||
} else {
|
||||
L10n.actionInvite
|
||||
}
|
||||
}
|
||||
|
||||
var isActionDisabled: Bool {
|
||||
isSkippable ? false : selectedUsers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct InviteUsersScreenViewStateBindings {
|
||||
|
||||
@@ -14,7 +14,7 @@ typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreen
|
||||
|
||||
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let roomType: InviteUsersScreenRoomType
|
||||
private let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
@@ -27,21 +27,24 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
roomType: InviteUsersScreenRoomType,
|
||||
isSkippable: Bool,
|
||||
userDiscoveryService: UserDiscoveryServiceProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
clientProxy = userSession.clientProxy
|
||||
self.roomProxy = roomProxy
|
||||
self.roomType = roomType
|
||||
self.userDiscoveryService = userDiscoveryService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
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),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
|
||||
setupSubscriptions()
|
||||
fetchMembersIfNeeded()
|
||||
|
||||
@@ -61,13 +64,18 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .proceed:
|
||||
guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
|
||||
!state.usersToConfirm.isEmpty,
|
||||
!state.isSkippable else {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
return
|
||||
switch roomType {
|
||||
case .draft:
|
||||
createDraftRoom(mandatoryUserIDs: state.selectedUsers.map(\.userID))
|
||||
case .existingRoom(let roomProxy):
|
||||
guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
|
||||
!state.usersToConfirm.isEmpty,
|
||||
!state.isSkippable else {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
return
|
||||
}
|
||||
state.bindings.presentConfirmationDialog = true
|
||||
}
|
||||
state.bindings.presentConfirmationDialog = true
|
||||
case .removeUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.selectedUsers.removeAll { user in
|
||||
@@ -77,7 +85,9 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .confirmUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.usersToConfirm = []
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
if case .existingRoom(let roomProxy) = roomType {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
}
|
||||
case .toggleUser(let user):
|
||||
toggleUser(user)
|
||||
}
|
||||
@@ -86,6 +96,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleUser(_ user: UserProfileProxy) {
|
||||
guard !state.isInviteeMandatory(user) else { return }
|
||||
|
||||
if state.selectedUsers.contains(user) {
|
||||
state.selectedUsers.removeAll { $0.userID == user.userID }
|
||||
} 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) {
|
||||
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, message: L10n.screenRoomDetailsInvitePeopleDontClose)
|
||||
|
||||
@@ -168,6 +203,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
|
||||
private func fetchMembersIfNeeded() {
|
||||
guard case .existingRoom(let roomProxy) = roomType else { return }
|
||||
|
||||
Task {
|
||||
showLoadingIndicator()
|
||||
await roomProxy.updateMembers()
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ struct InviteUsersConfirmationSheetView_Previews: PreviewProvider, TestablePrevi
|
||||
|
||||
static func makeViewModel() -> InviteUsersScreenViewModel {
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
roomType: .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: UserDiscoveryServiceMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
|
||||
@@ -110,7 +110,9 @@ struct InviteUsersScreen: View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
}
|
||||
.frame(width: selectedUserCellWidth)
|
||||
@@ -133,11 +135,18 @@ struct InviteUsersScreen: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(context.viewState.actionText) {
|
||||
context.send(viewAction: .proceed)
|
||||
if context.viewState.isSkippable, context.viewState.selectedUsers.isEmpty {
|
||||
Button(L10n.actionSkip) {
|
||||
context.send(viewAction: .proceed)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
} else {
|
||||
ToolbarButton(role: .confirm(title: L10n.actionInvite)) {
|
||||
context.send(viewAction: .proceed)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
.disabled(!context.viewState.hasInvitableSelectedUsers)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
.disabled(context.viewState.isActionDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +162,7 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let searchingViewModel = makeViewModel(searchQuery: "Alice")
|
||||
static let selectedViewModel = makeViewModel(hasSelection: true)
|
||||
static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true)
|
||||
static let draftViewModel = makeViewModel(roomType: .draft(mandatoryInvitees: [.mockAlice]), isSkippable: false)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
@@ -179,18 +189,28 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
InviteUsersScreen(context: confirmSelectedViewModel.context)
|
||||
}
|
||||
.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())
|
||||
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
|
||||
|
||||
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
|
||||
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
isSkippable: true,
|
||||
roomType: roomType ?? .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
|
||||
isSkippable: isSkippable,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: AppSettings())
|
||||
|
||||
+17
-7
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
struct InviteUsersScreenSelectedItem: View {
|
||||
let user: UserProfileProxy
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
var isLocked = false
|
||||
let dismissAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -25,18 +26,27 @@ struct InviteUsersScreenSelectedItem: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAction(named: L10n.actionRemove, dismissAction)
|
||||
.accessibilityActions {
|
||||
if !isLocked {
|
||||
Button(L10n.actionRemove, action: dismissAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
var avatar: some View {
|
||||
LoadableAvatarImage(url: user.avatarURL,
|
||||
name: user.displayName,
|
||||
contentID: user.userID,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlayRemoveItemButton(action: dismissAction)
|
||||
let avatarImage = LoadableAvatarImage(url: user.avatarURL,
|
||||
name: user.displayName,
|
||||
contentID: user.userID,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
mediaProvider: mediaProvider)
|
||||
if isLocked {
|
||||
avatarImage
|
||||
} else {
|
||||
avatarImage.overlayRemoveItemButton(action: dismissAction)
|
||||
}
|
||||
}
|
||||
|
||||
var closeButtonLabel: some View {
|
||||
|
||||
@@ -26,6 +26,7 @@ enum RoomDetailsScreenCoordinatorAction {
|
||||
case presentRoomDetailsEditScreen
|
||||
case presentNotificationSettingsScreen
|
||||
case presentInviteUsersScreen
|
||||
case presentInviteToNewRoom(invitee: UserProfileProxy)
|
||||
case presentPollsHistory
|
||||
case presentRolesAndPermissionsScreen
|
||||
case presentCall(isVoiceCall: Bool)
|
||||
@@ -71,6 +72,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentRoomMembersList)
|
||||
case .requestInvitePeoplePresentation:
|
||||
actionsSubject.send(.presentInviteUsersScreen)
|
||||
case .requestInviteToNewRoomPresentation(let invitee):
|
||||
actionsSubject.send(.presentInviteToNewRoom(invitee: invitee))
|
||||
case .leftRoom:
|
||||
actionsSubject.send(.leftRoom)
|
||||
case .requestEditDetailsPresentation:
|
||||
|
||||
@@ -18,6 +18,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
|
||||
case requestMemberDetailsPresentation
|
||||
case requestRecipientDetailsPresentation(userID: String)
|
||||
case requestInvitePeoplePresentation
|
||||
case requestInviteToNewRoomPresentation(selectedInvitee: UserProfileProxy)
|
||||
case leftRoom
|
||||
case requestEditDetailsPresentation
|
||||
case requestPollsHistoryPresentation
|
||||
@@ -98,6 +99,7 @@ struct RoomDetailsScreenViewState: BindableState {
|
||||
}
|
||||
shortcuts.append(.videoCall)
|
||||
}
|
||||
// The invite flow is different for DMs
|
||||
if dmRecipientInfo == nil, canInviteUsers {
|
||||
shortcuts.append(.invite)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
case .processTapPeople:
|
||||
actionsSubject.send(.requestMemberDetailsPresentation)
|
||||
case .processTapInvite:
|
||||
actionsSubject.send(.requestInvitePeoplePresentation)
|
||||
if let dmRecipient = state.dmRecipientInfo {
|
||||
actionsSubject.send(.requestInviteToNewRoomPresentation(selectedInvitee: .init(member: dmRecipient.member)))
|
||||
} else {
|
||||
actionsSubject.send(.requestInvitePeoplePresentation)
|
||||
}
|
||||
case .processTapLeave:
|
||||
processTapToLeave()
|
||||
case .confirmLeave:
|
||||
|
||||
@@ -22,6 +22,11 @@ struct RoomDetailsScreen: View {
|
||||
|
||||
aboutSection
|
||||
|
||||
// The invitation flow is different for DMs
|
||||
if context.viewState.dmRecipientInfo != nil {
|
||||
inviteToNewRoomSection
|
||||
}
|
||||
|
||||
configurationSection
|
||||
|
||||
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 {
|
||||
Section {
|
||||
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?,
|
||||
accessType: CreateRoomAccessType,
|
||||
isSpace: Bool,
|
||||
|
||||
@@ -183,7 +183,7 @@ protocol ClientProxyProtocol: AnyObject {
|
||||
|
||||
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
|
||||
|
||||
func createRoom(name: String,
|
||||
func createRoom(name: String?,
|
||||
topic: String?,
|
||||
accessType: CreateRoomAccessType,
|
||||
isSpace: Bool,
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f9413c3be57bf3882bff847d80d76616413d2c16a63ba316a760548fff2b6386
|
||||
size 151997
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e70eaef9bc01eccd462be75ac69e1d556784596a0287da9a8f3f7e10e4e58b0
|
||||
size 156413
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5338f0606064bc4c3c098c2458f1923db59f206c059cfa11bdbc387d42b3af3a
|
||||
size 99061
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c9456d7cf947a55063eae44ea955c34fd3af41663ffffbe1f72ba47f791cab0
|
||||
size 102079
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f2efff4b459ba93bc6c8290a3ff46a26a684b2c6abe2812c8a4667a86b060711
|
||||
size 151210
|
||||
oid sha256:5a87b4c36df926a3cde0d0a15022e5b5c0c0e545c97974f916b506b4862fc5ab
|
||||
size 149551
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a6fa5cea94e92da8768cb87d2d157b9552ec3624fbc4f1241c0c212e93f2d98
|
||||
size 155938
|
||||
oid sha256:d450d451e9ca7779f4f186f2eabf8a23cef9b2dd861e581ed1c61d0dbbb06499
|
||||
size 153569
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:34bbaf989ac4635edb87a1e2855c45af93eba45aae198a56173186014b79ddb3
|
||||
size 98426
|
||||
oid sha256:c793431f951a5ad023ab9b358658a5d75244e292cf56c0fc4a808b84df654e7a
|
||||
size 97084
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9cebab4fae25f0ea54e0583563719429d06d52949d7847458a98dec6c0ce746d
|
||||
size 101873
|
||||
oid sha256:68c222b7a50badb09b9f2cbdcc6010edac0a7a2727ed9f2f98713e2874ffa214
|
||||
size 100304
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
|
||||
size 244787
|
||||
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
|
||||
size 236706
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
|
||||
size 253574
|
||||
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
|
||||
size 244369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
|
||||
size 181065
|
||||
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
|
||||
size 174363
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
|
||||
size 185625
|
||||
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
|
||||
size 185369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
|
||||
size 244787
|
||||
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
|
||||
size 236706
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
|
||||
size 253574
|
||||
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
|
||||
size 244369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
|
||||
size 181065
|
||||
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
|
||||
size 174363
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
|
||||
size 185625
|
||||
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
|
||||
size 185369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
|
||||
size 244787
|
||||
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
|
||||
size 236706
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
|
||||
size 253574
|
||||
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
|
||||
size 244369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
|
||||
size 181065
|
||||
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
|
||||
size 174363
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
|
||||
size 185625
|
||||
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
|
||||
size 185369
|
||||
|
||||
@@ -87,6 +87,8 @@ final class InviteUsersScreenViewModelTests {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
return true
|
||||
case .openRoom:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +124,8 @@ final class InviteUsersScreenViewModelTests {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
return true
|
||||
case .openRoom:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +163,74 @@ final class InviteUsersScreenViewModelTests {
|
||||
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
|
||||
|
||||
private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) {
|
||||
@@ -168,7 +240,7 @@ final class InviteUsersScreenViewModelTests {
|
||||
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
|
||||
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomProxy: roomProxy,
|
||||
roomType: .existingRoom(roomProxy: roomProxy),
|
||||
isSkippable: isSkippable,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
|
||||
Reference in New Issue
Block a user