diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index d1abc0d5f..1cdf89647 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -30,6 +30,7 @@ final class AppSettings { case startChatFlowEnabled case startChatUserSuggestionsEnabled case invitesFlowEnabled + case inviteMorePeopleFlowEnabled case readReceiptsEnabled } @@ -187,6 +188,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.invitesFlowEnabled, defaultValue: false, storageType: .userDefaults(store)) var invitesFlowEnabled + + @UserPreference(key: UserDefaultsKeys.inviteMorePeopleFlowEnabled, defaultValue: false, storageType: .userDefaults(store)) + var inviteMorePeopleFlowEnabled // MARK: Receipts diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7a569799d..a131f68f5 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -331,6 +331,11 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol { set(value) { underlyingIsIgnored = value } } var underlyingIsIgnored: Bool! + var canInviteUsers: Bool { + get { return underlyingCanInviteUsers } + set(value) { underlyingCanInviteUsers = value } + } + var underlyingCanInviteUsers: Bool! //MARK: - ignoreUser @@ -869,6 +874,27 @@ class RoomProxyMock: RoomProxyProtocol { fetchDetailsForReceivedInvocations.append(eventID) fetchDetailsForClosure?(eventID) } + //MARK: - invite + + var inviteUserIDCallsCount = 0 + var inviteUserIDCalled: Bool { + return inviteUserIDCallsCount > 0 + } + var inviteUserIDReceivedUserID: String? + var inviteUserIDReceivedInvocations: [String] = [] + var inviteUserIDReturnValue: Result! + var inviteUserIDClosure: ((String) async -> Result)? + + func invite(userID: String) async -> Result { + inviteUserIDCallsCount += 1 + inviteUserIDReceivedUserID = userID + inviteUserIDReceivedInvocations.append(userID) + if let inviteUserIDClosure = inviteUserIDClosure { + return await inviteUserIDClosure(userID) + } else { + return inviteUserIDReturnValue + } + } } class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index 432c1cc12..9a841c105 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -22,11 +22,12 @@ struct RoomMemberProxyMockConfiguration { var displayName: String var avatarURL: URL? var membership: MembershipState - var isNameAmbiguous: Bool + var isNameAmbiguous = false var powerLevel: Int var normalizedPowerLevel: Int - var isAccountOwner: Bool - var isIgnored: Bool + var isAccountOwner = false + var isIgnored = false + var canInviteUsers = false } extension RoomMemberProxyMock { @@ -41,6 +42,7 @@ extension RoomMemberProxyMock { normalizedPowerLevel = configuration.normalizedPowerLevel isAccountOwner = configuration.isAccountOwner isIgnored = configuration.isIgnored + canInviteUsers = configuration.canInviteUsers } // Mocks @@ -49,11 +51,8 @@ extension RoomMemberProxyMock { displayName: "Alice", avatarURL: nil, membership: .join, - isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50, - isAccountOwner: false, - isIgnored: false)) + normalizedPowerLevel: 50)) } static var mockInvitedAlice: RoomMemberProxyMock { @@ -61,11 +60,8 @@ extension RoomMemberProxyMock { displayName: "Alice", avatarURL: nil, membership: .invite, - isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50, - isAccountOwner: false, - isIgnored: false)) + normalizedPowerLevel: 50)) } static var mockBob: RoomMemberProxyMock { @@ -73,11 +69,8 @@ extension RoomMemberProxyMock { displayName: "Bob", avatarURL: nil, membership: .join, - isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50, - isAccountOwner: false, - isIgnored: false)) + normalizedPowerLevel: 50)) } static var mockCharlie: RoomMemberProxyMock { @@ -85,11 +78,8 @@ extension RoomMemberProxyMock { displayName: "Charlie", avatarURL: nil, membership: .join, - isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50, - isAccountOwner: false, - isIgnored: false)) + normalizedPowerLevel: 50)) } static var mockDan: RoomMemberProxyMock { @@ -97,11 +87,8 @@ extension RoomMemberProxyMock { displayName: "Dan", avatarURL: URL.picturesDirectory, membership: .join, - isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50, - isAccountOwner: false, - isIgnored: false)) + normalizedPowerLevel: 50)) } static var mockMe: RoomMemberProxyMock { @@ -109,11 +96,10 @@ extension RoomMemberProxyMock { displayName: "Me", avatarURL: URL.picturesDirectory, membership: .join, - isNameAmbiguous: false, powerLevel: 50, normalizedPowerLevel: 50, isAccountOwner: true, - isIgnored: false)) + canInviteUsers: true)) } static var mockIgnored: RoomMemberProxyMock { @@ -121,10 +107,8 @@ extension RoomMemberProxyMock { displayName: "Ignored", avatarURL: nil, membership: .join, - isNameAmbiguous: false, powerLevel: 50, normalizedPowerLevel: 50, - isAccountOwner: false, isIgnored: true)) } } diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index a81c9fbdf..a982bba90 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -97,6 +97,7 @@ struct A11yIdentifiers { let avatar = "room_details-avatar" let dmAvatar = "room_details-dm_avatar" let people = "room_details-people" + let invite = "room_details-invite" } struct RoomMemberDetailsScreen { diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift index 2a684e516..52f89e71a 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift @@ -44,7 +44,8 @@ enum FormRowAccessory: View { /// to change the background colour depending on whether the button is currently pressed or not. struct FormButtonStyle: PrimitiveButtonStyle { var iconAlignment: VerticalAlignment = .firstTextBaseline - + /// Disables the button while providing an entry point for UI customizations + var isDisabled = false /// An accessory to be added on the trailing side of the row. var accessory: FormRowAccessory? @@ -54,12 +55,14 @@ struct FormButtonStyle: PrimitiveButtonStyle { .labelStyle(FormRowLabelStyle(alignment: iconAlignment, role: configuration.role)) .frame(maxHeight: .infinity) // Make sure the label fills the cell vertically. } - .buttonStyle(Style(accessory: accessory)) + .buttonStyle(Style(isDisabled: isDisabled, accessory: accessory)) .listRowInsets(EdgeInsets()) // Remove insets so the background fills the cell. + .disabled(isDisabled) } /// Inner style used to set the pressed background colour. struct Style: ButtonStyle { + var isDisabled: Bool var accessory: FormRowAccessory? func makeBody(configuration: Configuration) -> some View { @@ -70,6 +73,7 @@ struct FormButtonStyle: PrimitiveButtonStyle { .frame(maxWidth: .infinity, alignment: .leading) accessory + .opacity(isDisabled ? 0.54 : 1) } .contentShape(Rectangle()) .padding(FormRow.insets) // Re-apply the normal insets using padding. @@ -132,7 +136,15 @@ struct FormButtonStyles_Previews: PreviewProvider { } .buttonStyle(FormButtonStyle()) Button { } label: { - Text("Selection") + Text("Selected") + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + Button { } label: { + Text("Selected (disabled)") + } + .buttonStyle(FormButtonStyle(isDisabled: true, accessory: .selection(isSelected: true))) + Button { } label: { + Text("Unselected") } .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) } diff --git a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift index 70e99f8b8..0977bc84c 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift @@ -14,10 +14,12 @@ // limitations under the License. // +import MatrixRustSDK import SwiftUI struct UserProfileCell: View { let user: UserProfile + let membership: MembershipState? let imageProvider: ImageProviderProtocol? var body: some View { @@ -35,25 +37,92 @@ struct UserProfileCell: View { .font(.compound.headingSM) .foregroundColor(.element.primaryContent) - if user.displayName != nil { - Text(user.userID) - .font(.compound.bodyMD) - .foregroundColor(.element.tertiaryContent) - } + subtitleContent - if !user.isVerified { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(Image(systemName: "exclamationmark.circle.fill")) - .foregroundColor(.compound.textCriticalPrimary) - - Text(L10n.screenStartChatUnknownProfile) - .foregroundColor(.secondary) - } - .font(.compound.bodyXS) - .padding(.top, 4) - } + warningContent } .accessibilityElement(children: .combine) } } + + // MARK: - Private + + @ViewBuilder + private var subtitleContent: some View { + if let membershipText = membership?.localizedDescription { + Text(membershipText) + .font(.compound.bodyMD) + .foregroundColor(.element.tertiaryContent) + } else if user.displayName != nil { + Text(user.userID) + .font(.compound.bodyMD) + .foregroundColor(.element.tertiaryContent) + } + } + + @ViewBuilder + private var warningContent: some View { + if !user.isVerified, membership == nil { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(Image(systemName: "exclamationmark.circle.fill")) + .foregroundColor(.compound.textCriticalPrimary) + + Text(L10n.screenStartChatUnknownProfile) + .foregroundColor(.secondary) + } + .font(.compound.bodyXS) + .padding(.top, 4) + } + } +} + +private extension MembershipState { + var localizedDescription: String? { + switch self { + case .join: + return L10n.screenRoomDetailsAlreadyAMember + case .invite: + return L10n.screenRoomDetailsAlreadyInvited + default: + return nil + } + } +} + +struct UserProfileCell_Previews: PreviewProvider { + static let action: () -> Void = { } + + static var previews: some View { + Form { + Button(action: action) { + UserProfileCell(user: .mockAlice, membership: nil, imageProvider: MockMediaProvider()) + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .previewDisplayName("Selected user") + + Button(action: action) { + UserProfileCell(user: .mockBob, membership: nil, imageProvider: MockMediaProvider()) + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) + .previewDisplayName("Unselected user") + + Button(action: action) { + UserProfileCell(user: .mockCharlie, membership: .join, imageProvider: MockMediaProvider()) + } + .buttonStyle(FormButtonStyle(isDisabled: true, accessory: .selection(isSelected: true))) + .previewDisplayName("Selected disabled user") + + Button(action: action) { + UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: .join, imageProvider: MockMediaProvider()) + } + .buttonStyle(FormButtonStyle(isDisabled: true, accessory: .selection(isSelected: true))) + .previewDisplayName("Unverified joined user") + + Button(action: action) { + UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: nil, imageProvider: MockMediaProvider()) + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) + .previewDisplayName("Unverified user") + } + } } diff --git a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift b/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift index 607593de8..e10e50318 100644 --- a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift +++ b/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift @@ -22,4 +22,6 @@ class MockUserIndicatorController: UserIndicatorControllerProtocol { func retractIndicatorWithId(_ id: String) { } func retractAllIndicators() { } + + var alertInfo: AlertInfo? } diff --git a/ElementX/Sources/Other/UserIndicator/UserIndicatorController.swift b/ElementX/Sources/Other/UserIndicator/UserIndicatorController.swift index 813dfa846..61c9abb29 100644 --- a/ElementX/Sources/Other/UserIndicator/UserIndicatorController.swift +++ b/ElementX/Sources/Other/UserIndicator/UserIndicatorController.swift @@ -39,6 +39,8 @@ class UserIndicatorController: ObservableObject, UserIndicatorControllerProtocol } } + @Published var alertInfo: AlertInfo? + init(rootCoordinator: CoordinatorProtocol) { self.rootCoordinator = rootCoordinator } diff --git a/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift b/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift index 14537d671..e65aac20e 100644 --- a/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift +++ b/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift @@ -20,4 +20,5 @@ protocol UserIndicatorControllerProtocol: CoordinatorProtocol { func submitIndicator(_ indicator: UserIndicator) func retractIndicatorWithId(_ id: String) func retractAllIndicators() + var alertInfo: AlertInfo? { get set } } diff --git a/ElementX/Sources/Other/UserIndicator/UserIndicatorPresenter.swift b/ElementX/Sources/Other/UserIndicator/UserIndicatorPresenter.swift index 56efc4bfb..215bf8643 100644 --- a/ElementX/Sources/Other/UserIndicator/UserIndicatorPresenter.swift +++ b/ElementX/Sources/Other/UserIndicator/UserIndicatorPresenter.swift @@ -40,5 +40,8 @@ struct UserIndicatorPresenter: View { } } } + .alert(item: $userIndicatorController.alertInfo) { + $0.alert + } } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index f757d73a3..82c67944f 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -29,6 +29,7 @@ struct DeveloperOptionsScreenViewStateBindings { var startChatFlowEnabled: Bool var startChatUserSuggestionsEnabled: Bool var invitesFlowEnabled: Bool + var inviteMorePeopleFlowEnabled: Bool } enum DeveloperOptionsScreenViewAction { @@ -36,5 +37,6 @@ enum DeveloperOptionsScreenViewAction { case changedStartChatFlowEnabled case changedStartChatUserSuggestionsEnabled case changedInvitesFlowEnabled + case changedInviteMorePeopleFlowEnabled case clearCache } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index c5923ea22..d8f34c63d 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -21,16 +21,20 @@ typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel Void)? + private let appSettings: AppSettings + init() { - let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, - startChatFlowEnabled: ServiceLocator.shared.settings.startChatFlowEnabled, - startChatUserSuggestionsEnabled: ServiceLocator.shared.settings.startChatUserSuggestionsEnabled, - invitesFlowEnabled: ServiceLocator.shared.settings.invitesFlowEnabled) + appSettings = ServiceLocator.shared.settings + let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: appSettings.shouldCollapseRoomStateEvents, + startChatFlowEnabled: appSettings.startChatFlowEnabled, + startChatUserSuggestionsEnabled: appSettings.startChatUserSuggestionsEnabled, + invitesFlowEnabled: appSettings.invitesFlowEnabled, + inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled) let state = DeveloperOptionsScreenViewState(bindings: bindings) super.init(initialViewState: state) - ServiceLocator.shared.settings.$shouldCollapseRoomStateEvents + appSettings.$shouldCollapseRoomStateEvents .weakAssign(to: \.state.bindings.shouldCollapseRoomStateEvents, on: self) .store(in: &cancellables) } @@ -38,13 +42,15 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve override func process(viewAction: DeveloperOptionsScreenViewAction) { switch viewAction { case .changedShouldCollapseRoomStateEvents: - ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents + appSettings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents case .changedStartChatFlowEnabled: - ServiceLocator.shared.settings.startChatFlowEnabled = state.bindings.startChatFlowEnabled + appSettings.startChatFlowEnabled = state.bindings.startChatFlowEnabled case .changedStartChatUserSuggestionsEnabled: - ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled + appSettings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled case .changedInvitesFlowEnabled: - ServiceLocator.shared.settings.invitesFlowEnabled = state.bindings.invitesFlowEnabled + appSettings.invitesFlowEnabled = state.bindings.invitesFlowEnabled + case .changedInviteMorePeopleFlowEnabled: + appSettings.inviteMorePeopleFlowEnabled = state.bindings.inviteMorePeopleFlowEnabled case .clearCache: callback?(.clearCache) } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 589eea6f8..3f7ffad63 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -31,7 +31,7 @@ struct DeveloperOptionsScreen: View { } Toggle(isOn: $context.startChatFlowEnabled) { - Text("Show Start Chat flow") + Text("Show start chat flow") } .onChange(of: context.startChatFlowEnabled) { _ in context.send(viewAction: .changedStartChatFlowEnabled) @@ -45,11 +45,18 @@ struct DeveloperOptionsScreen: View { } Toggle(isOn: $context.invitesFlowEnabled) { - Text("Show Invites flow") + Text("Show invites flow") } .onChange(of: context.invitesFlowEnabled) { _ in context.send(viewAction: .changedInvitesFlowEnabled) } + + Toggle(isOn: $context.inviteMorePeopleFlowEnabled) { + Text("Show invite more people flow") + } + .onChange(of: context.inviteMorePeopleFlowEnabled) { _ in + context.send(viewAction: .changedInviteMorePeopleFlowEnabled) + } } Section { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index b6ceecb74..7e23fcc38 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -337,7 +337,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol defer { ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID) } - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true)) + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true)) let room = await userSession.clientProxy.roomForIdentifier(roomId) let result = await room?.leaveRoom() diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift index ed994b5b9..e4190c105 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift @@ -18,14 +18,15 @@ import Combine import SwiftUI struct InviteUsersScreenCoordinatorParameters { - let userSession: UserSessionProtocol - let userDiscoveryService: UserDiscoveryServiceProtocol let selectedUsers: CurrentValuePublisher<[UserProfile], Never> + let roomType: InviteUsersScreenRoomType + let mediaProvider: MediaProviderProtocol + let userDiscoveryService: UserDiscoveryServiceProtocol } enum InviteUsersScreenCoordinatorAction { - case close case proceed + case invite(users: [String]) case toggleUser(UserProfile) } @@ -42,17 +43,20 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol { init(parameters: InviteUsersScreenCoordinatorParameters) { self.parameters = parameters - viewModel = InviteUsersScreenViewModel(selectedUsers: parameters.selectedUsers, userSession: parameters.userSession, userDiscoveryService: parameters.userDiscoveryService) + viewModel = InviteUsersScreenViewModel(selectedUsers: parameters.selectedUsers, + roomType: parameters.roomType, + mediaProvider: parameters.mediaProvider, + userDiscoveryService: parameters.userDiscoveryService) } func start() { viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .close: - self.actionsSubject.send(.close) case .proceed: self.actionsSubject.send(.proceed) + case .invite(let users): + self.actionsSubject.send(.invite(users: users)) case .toggleUser(let user): self.actionsSubject.send(.toggleUser(user)) } diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift index 778ef2b91..21c9b819c 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift @@ -15,23 +15,30 @@ // import Foundation +import MatrixRustSDK enum InviteUsersScreenErrorType: Error { case unknown } enum InviteUsersScreenViewModelAction { - case close case proceed + case invite(users: [String]) case toggleUser(UserProfile) } +enum InviteUsersScreenRoomType { + case draft + case room(members: [RoomMemberProxyProtocol], userIndicatorController: UserIndicatorControllerProtocol) +} + struct InviteUsersScreenViewState: BindableState { var bindings = InviteUsersScreenViewStateBindings() var usersSection: UserDiscoverySection = .init(type: .suggestions, users: []) var selectedUsers: [UserProfile] = [] + var membershipState: [String: MembershipState] = .init() var isSearching: Bool { !bindings.searchQuery.isEmpty @@ -44,7 +51,30 @@ struct InviteUsersScreenViewState: BindableState { var scrollToLastID: String? func isUserSelected(_ user: UserProfile) -> Bool { - selectedUsers.contains { $0.userID == user.userID } + isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID } + } + + func isUserDisabled(_ user: UserProfile) -> Bool { + let membershipState = membershipState(user) + return membershipState == .invite || membershipState == .join + } + + func membershipState(_ user: UserProfile) -> MembershipState? { + membershipState[user.userID] + } + + let isCreatingRoom: Bool + + var actionText: String { + if isCreatingRoom { + return selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext + } else { + return L10n.actionInvite + } + } + + var isActionDisabled: Bool { + isCreatingRoom ? false : selectedUsers.isEmpty } } @@ -56,7 +86,6 @@ struct InviteUsersScreenViewStateBindings { } enum InviteUsersScreenViewAction { - case close case proceed case toggleUser(UserProfile) } diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift index 7650210f1..b1c3ad5aa 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift @@ -15,49 +15,77 @@ // import Combine +import MatrixRustSDK import SwiftUI typealias InviteUsersScreenViewModelType = StateStoreViewModel class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol { - private let userSession: UserSessionProtocol + private let mediaProvider: MediaProviderProtocol private let userDiscoveryService: UserDiscoveryServiceProtocol + private let roomType: InviteUsersScreenRoomType private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(selectedUsers: CurrentValuePublisher<[UserProfile], Never>, userSession: UserSessionProtocol, userDiscoveryService: UserDiscoveryServiceProtocol) { - self.userSession = userSession + init(selectedUsers: CurrentValuePublisher<[UserProfile], Never>, + roomType: InviteUsersScreenRoomType, + mediaProvider: MediaProviderProtocol, + userDiscoveryService: UserDiscoveryServiceProtocol) { + self.roomType = roomType + self.mediaProvider = mediaProvider self.userDiscoveryService = userDiscoveryService - super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value), imageProvider: userSession.mediaProvider) - - selectedUsers - .sink { [weak self] users in - self?.state.selectedUsers = users - } - .store(in: &cancellables) - - setupSubscriptions() + super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value, isCreatingRoom: roomType.isCreatingRoom), imageProvider: mediaProvider) + + buildMembershipStateIfNeeded() + setupSubscriptions(selectedUsers: selectedUsers) } // MARK: - Public override func process(viewAction: InviteUsersScreenViewAction) { switch viewAction { - case .close: - actionsSubject.send(.close) case .proceed: - actionsSubject.send(.proceed) + switch roomType { + case .draft: + actionsSubject.send(.proceed) + case .room: + actionsSubject.send(.invite(users: state.selectedUsers.map(\.userID))) + } case .toggleUser(let user): actionsSubject.send(.toggleUser(user)) } } + + private func buildMembershipStateIfNeeded() { + guard case let .room(members, userIndicatorController) = roomType else { + return + } + let indicatorID = UUID().uuidString + userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, type: .modal, title: L10n.commonLoading, persistent: true)) + + Task.detached { [members] in + // accessing RoomMember's properties is very slow. We need to do it in a background thread. + let membershipState = members + .reduce(into: [String: MembershipState]()) { partialResult, member in + partialResult[member.userID] = member.membership + } + + Task { @MainActor in + self.state.membershipState = membershipState + userIndicatorController.retractIndicatorWithId(indicatorID) + } + } + } // MARK: - Private - private func setupSubscriptions() { + @CancellableTask + private var fetchUsersTask: Task? + + private func setupSubscriptions(selectedUsers: CurrentValuePublisher<[UserProfile], Never>) { context.$viewState .map(\.bindings.searchQuery) .debounceAndRemoveDuplicates() @@ -65,11 +93,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr self?.fetchUsers() } .store(in: &cancellables) + + selectedUsers + .sink { [weak self] users in + self?.state.selectedUsers = users + } + .store(in: &cancellables) } - @CancellableTask - private var fetchUsersTask: Task? - private func fetchUsers() { guard searchQuery.count >= 3 else { fetchSuggestions() @@ -107,3 +138,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr context.searchQuery } } + +private extension InviteUsersScreenRoomType { + var isCreatingRoom: Bool { + switch self { + case .draft: + return true + case .room: + return false + } + } +} diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift index 545bc11da..1a6c3c56e 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift @@ -69,9 +69,11 @@ struct InviteUsersScreen: View { ForEach(context.viewState.usersSection.users, id: \.userID) { user in Button { context.send(viewAction: .toggleUser(user)) } label: { UserProfileCell(user: user, + membership: context.viewState.membershipState(user), imageProvider: context.imageProvider) } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user)))) + .buttonStyle(FormButtonStyle(isDisabled: context.viewState.isUserDisabled(user), + accessory: .selection(isSelected: context.viewState.isUserSelected(user)))) } } header: { if let title = context.viewState.usersSection.title { @@ -107,8 +109,9 @@ struct InviteUsersScreen: View { private var nextButton: some View { Button { context.send(viewAction: .proceed) } label: { - Text(context.viewState.selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext) + Text(context.viewState.actionText) } + .disabled(context.viewState.isActionDisabled) } private func deselect(_ user: UserProfile) { @@ -120,12 +123,10 @@ struct InviteUsersScreen: View { struct InviteUsersScreen_Previews: PreviewProvider { static let viewModel = { - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), - mediaProvider: MockMediaProvider()) let userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice]) userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice]) - return InviteUsersScreenViewModel(selectedUsers: .init([]), userSession: userSession, userDiscoveryService: userDiscoveryService) + return InviteUsersScreenViewModel(selectedUsers: .init([]), roomType: .draft, mediaProvider: MockMediaProvider(), userDiscoveryService: userDiscoveryService) }() static var previews: some View { diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift index ca131a106..745a6c87c 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift @@ -14,12 +14,14 @@ // limitations under the License. // +import Combine import SwiftUI struct RoomDetailsScreenCoordinatorParameters { let navigationStackCoordinator: NavigationStackCoordinator let roomProxy: RoomProxyProtocol let mediaProvider: MediaProviderProtocol + let userDiscoveryService: UserDiscoveryServiceProtocol } enum RoomDetailsScreenCoordinatorAction { @@ -30,7 +32,11 @@ enum RoomDetailsScreenCoordinatorAction { final class RoomDetailsScreenCoordinator: CoordinatorProtocol { private let parameters: RoomDetailsScreenCoordinatorParameters private var viewModel: RoomDetailsScreenViewModelProtocol - private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator } + private var cancellables: Set = .init() + private let selectedUsers: CurrentValueSubject<[UserProfile], Never> = .init([]) + private var navigationStackCoordinator: NavigationStackCoordinator { + parameters.navigationStackCoordinator + } var callback: ((RoomDetailsScreenCoordinatorAction) -> Void)? @@ -50,6 +56,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { switch action { case .requestMemberDetailsPresentation(let members): self.presentRoomMembersList(members) + case .requestInvitePeoplePresentation(let members): + self.presentInviteUsersScreen(members: members) case .cancel: self.callback?(.cancel) case .leftRoom: @@ -62,12 +70,96 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { AnyView(RoomDetailsScreen(context: viewModel.context)) } + // MARK: - Private + private func presentRoomMembersList(_ members: [RoomMemberProxyProtocol]) { let params = RoomMembersListScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, mediaProvider: parameters.mediaProvider, members: members) let coordinator = RoomMembersListScreenCoordinator(parameters: params) + coordinator.callback = { [weak self] action in + switch action { + case .invite: + self?.presentInviteUsersScreen(members: members) + } + } + navigationStackCoordinator.push(coordinator) } + + private func presentInviteUsersScreen(members: [RoomMemberProxyProtocol]) { + let navigationStackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator) + let inviteParameters = InviteUsersScreenCoordinatorParameters(selectedUsers: .init(selectedUsers), + roomType: .room(members: members, userIndicatorController: userIndicatorController), + mediaProvider: parameters.mediaProvider, + userDiscoveryService: parameters.userDiscoveryService) + + let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters) + navigationStackCoordinator.setRootCoordinator(coordinator) + + coordinator.actions.sink { [weak self] result in + guard let self else { return } + + switch result { + case .proceed: + break + case .invite(let users): + self.inviteUsers(users, in: parameters.roomProxy) + case .toggleUser(let user): + self.toggleUser(user) + } + } + .store(in: &cancellables) + + parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController) + } + + private func toggleUser(_ user: UserProfile) { + var selectedUsers = selectedUsers.value + if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { + selectedUsers.remove(at: index) + } else { + selectedUsers.append(user) + } + self.selectedUsers.send(selectedUsers) + } + + private func inviteUsers(_ users: [String], in room: RoomProxyProtocol) { + navigationStackCoordinator.setSheetCoordinator(nil) + + Task { + let result: Result = await withTaskGroup(of: Result.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 + } + + ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.commonUnableToInviteTitle, + message: L10n.commonUnableToInviteMessage) + } + } +} + +private extension Result { + var isFailure: Bool { + switch self { + case .success: + return false + case .failure: + return true + } + } } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift index b6ee31a8a..e0b2c8d6d 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift @@ -23,6 +23,7 @@ import UIKit enum RoomDetailsScreenViewModelAction { case requestMemberDetailsPresentation([RoomMemberProxyProtocol]) + case requestInvitePeoplePresentation([RoomMemberProxyProtocol]) case leftRoom case cancel } @@ -41,6 +42,7 @@ struct RoomDetailsScreenViewState: BindableState { var members: [RoomMemberDetails] = [] var joinedMembersCount = 0 var isProcessingIgnoreRequest = false + var canInviteUsers = false var isLoadingMembers: Bool { members.isEmpty @@ -124,6 +126,7 @@ struct LeaveRoomAlertItem: AlertItem { enum RoomDetailsScreenViewAction { case processTapPeople + case processTapInvite case processTapLeave case processTapIgnore case processTapUnignore diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index f25db99bb..9cb1e01fe 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -56,6 +56,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr switch viewAction { case .processTapPeople: callback?(.requestMemberDetailsPresentation(members)) + case .processTapInvite: + callback?(.requestInvitePeoplePresentation(members)) case .processTapLeave: let joinedMembers = members.filter { $0.membership == .join } guard joinedMembers.count > 1 else { @@ -84,27 +86,29 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr guard let self else { return } buildMembersDetailsTask = Task { - let (membersDetails, joinedMembersCount) = await self.buildMembersDetails(members: members) + let roomMembersDetails = await self.buildMembersDetails(members: members) guard !Task.isCancelled else { return } if self.roomProxy.isDirect, self.roomProxy.isEncrypted, members.count == 2 { self.dmRecipient = members.first(where: { !$0.isAccountOwner }) } - - self.state.members = membersDetails - self.state.joinedMembersCount = joinedMembersCount + + self.state.members = roomMembersDetails.members + self.state.joinedMembersCount = roomMembersDetails.joinedMembersCount self.state.dmRecipient = self.dmRecipient.map(RoomMemberDetails.init(withProxy:)) + self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false self.members = members } } .store(in: &cancellables) } - private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> (memberDetails: [RoomMemberDetails], joinedMembersCount: Int) { + private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails { await Task.detached { // accessing RoomMember's properties is very slow. We need to do it in a background thread. var roomMembersDetails: [RoomMemberDetails] = [] + var accountOwner: RoomMemberProxyProtocol? var joinedMembersCount = 0 roomMembersDetails.reserveCapacity(members.count) @@ -114,9 +118,13 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr if member.membership == .join { joinedMembersCount += 1 } + + if accountOwner == nil, member.isAccountOwner { + accountOwner = member + } } - return (roomMembersDetails, joinedMembersCount) + return .init(members: roomMembersDetails, joinedMembersCount: joinedMembersCount, accountOwner: accountOwner) } .value } @@ -124,7 +132,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr private static let leaveRoomLoadingID = "LeaveRoomLoading" private func leaveRoom() async { - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true)) + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true)) let result = await roomProxy.leaveRoom() ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID) switch result { @@ -159,3 +167,9 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr } } } + +private struct RoomMembersDetails { + let members: [RoomMemberDetails] + let joinedMembersCount: Int + let accountOwner: RoomMemberProxyProtocol? +} diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift index 431f740ed..e8083bb8e 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift @@ -128,12 +128,22 @@ struct RoomDetailsScreen: View { Label(L10n.commonPeople, systemImage: "person") } } - .buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink)) - .foregroundColor(.element.primaryContent) .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people) - .disabled(context.viewState.isLoadingMembers) + + if context.viewState.canInviteUsers, ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled { + Button { + context.send(viewAction: .processTapInvite) + } label: { + Label(L10n.screenRoomDetailsInvitePeopleTitle, systemImage: "person.badge.plus") + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.invite) + } } - .formSectionStyle() + .listRowSeparatorTint(.element.quinaryContent) + .buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink)) + .foregroundColor(.element.primaryContent) + + .disabled(context.viewState.isLoadingMembers) } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift index af9291449..4d7c7f78d 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift @@ -22,7 +22,9 @@ struct RoomMembersListScreenCoordinatorParameters { let members: [RoomMemberProxyProtocol] } -enum RoomMembersListScreenCoordinatorAction { } +enum RoomMembersListScreenCoordinatorAction { + case invite +} final class RoomMembersListScreenCoordinator: CoordinatorProtocol { private let parameters: RoomMembersListScreenCoordinatorParameters @@ -45,6 +47,8 @@ final class RoomMembersListScreenCoordinator: CoordinatorProtocol { switch action { case let .selectMember(member): self.selectMember(member) + case .invite: + callback?(.invite) } } } diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift index 690822edb..c475fdac7 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift @@ -18,11 +18,13 @@ import Foundation enum RoomMembersListScreenViewModelAction { case selectMember(_ member: RoomMemberProxyProtocol) + case invite } struct RoomMembersListScreenViewState: BindableState { private var joinedMembers: [RoomMemberDetails] private var invitedMembers: [RoomMemberDetails] + var canInviteUsers = false var bindings: RoomMembersListScreenViewStateBindings init(joinedMembers: [RoomMemberDetails] = [], invitedMembers: [RoomMemberDetails] = [], bindings: RoomMembersListScreenViewStateBindings = .init()) { @@ -59,6 +61,7 @@ struct RoomMembersListScreenViewStateBindings { enum RoomMembersListScreenViewAction { case selectMember(id: String) + case invite } private extension RoomMemberDetails { diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift index 1b80a34eb..ca55a0129 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift @@ -29,7 +29,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe self.members = members super.init(initialViewState: .init(), imageProvider: mediaProvider) - buildMembersDetails(members: members) + setupState(members: members) } // MARK: - Public @@ -42,12 +42,14 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe return } callback?(.selectMember(member)) + case .invite: + callback?(.invite) } } // MARK: - Private - func buildMembersDetails(members: [RoomMemberProxyProtocol]) { + private func setupState(members: [RoomMemberProxyProtocol]) { Task { let indicatorId = UUID().uuidString defer { @@ -55,24 +57,42 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe } ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: indicatorId, type: .modal, title: L10n.commonLoading, persistent: true)) - let (invitedMembers, joinedMembers) = await split(members: members) - self.state = .init(joinedMembers: joinedMembers, invitedMembers: invitedMembers) + let roomMembersDetails = await buildMembersDetails(members: members) + self.state = .init(joinedMembers: roomMembersDetails.joinedMembers, invitedMembers: roomMembersDetails.invitedMembers) + self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false } } - func split(members: [RoomMemberProxyProtocol]) async -> (invited: [RoomMemberDetails], joined: [RoomMemberDetails]) { + private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails { await Task.detached { // accessing RoomMember's properties is very slow. We need to do it in a background thread. - let invitedMembers = members - .filter { $0.membership == .invite } - .map(RoomMemberDetails.init(withProxy:)) + var invitedMembers: [RoomMemberDetails] = .init() + var joinedMembers: [RoomMemberDetails] = .init() + var accountOwner: RoomMemberProxyProtocol? - let joinedMembers = members - .filter { $0.membership == .join } - .map(RoomMemberDetails.init(withProxy:)) + for member in members { + if accountOwner == nil, member.isAccountOwner { + accountOwner = member + } + + switch member.membership { + case .invite: + invitedMembers.append(.init(withProxy: member)) + case .join: + joinedMembers.append(.init(withProxy: member)) + default: + continue + } + } - return (invitedMembers, joinedMembers) + return .init(invitedMembers: invitedMembers, joinedMembers: joinedMembers, accountOwner: accountOwner) } .value } } + +private struct RoomMembersDetails { + var invitedMembers: [RoomMemberDetails] + var joinedMembers: [RoomMemberDetails] + var accountOwner: RoomMemberProxyProtocol? +} diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift index c9051760a..509e52b82 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift @@ -34,6 +34,11 @@ struct RoomMembersListScreen: View { .background(Color.element.background.ignoresSafeArea()) .navigationTitle(L10n.commonPeople) .alert(item: $context.alertInfo) { $0.alert } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + inviteButton + } + } } // MARK: - Private @@ -53,6 +58,17 @@ struct RoomMembersListScreen: View { } .padding(.horizontal) } + + @ViewBuilder + private var inviteButton: some View { + if context.viewState.canInviteUsers, ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled { + Button { + context.send(viewAction: .invite) + } label: { + Text(L10n.actionInvite) + } + } + } } // MARK: - Previews diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 74bf97240..dd1edc642 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -22,6 +22,7 @@ struct RoomScreenCoordinatorParameters { let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let emojiProvider: EmojiProviderProtocol + let userDiscoveryService: UserDiscoveryServiceProtocol } enum RoomScreenCoordinatorAction { @@ -163,7 +164,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { private func displayRoomDetails() { let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, roomProxy: parameters.roomProxy, - mediaProvider: parameters.mediaProvider) + mediaProvider: parameters.mediaProvider, + userDiscoveryService: parameters.userDiscoveryService) let coordinator = RoomDetailsScreenCoordinator(parameters: params) coordinator.callback = { [weak self] action in switch action { diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift index 805ab5efc..f464f1bc9 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift @@ -80,18 +80,19 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { // MARK: - Private private func presentInviteUsersScreen() { - let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: parameters.userSession, - userDiscoveryService: parameters.userDiscoveryService, - selectedUsers: selectedUsersPublisher) + let inviteParameters = InviteUsersScreenCoordinatorParameters(selectedUsers: selectedUsersPublisher, + roomType: .draft, + mediaProvider: parameters.userSession.mediaProvider, + userDiscoveryService: parameters.userDiscoveryService) let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters) coordinator.actions.sink { [weak self] result in guard let self else { return } switch result { - case .close: - parameters.navigationStackCoordinator?.pop() case .proceed: openCreateRoomScreen() + case .invite: + break case .toggleUser(let user): toggleUser(user) } diff --git a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift index dd9fb8d1e..1bfb844b8 100644 --- a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift @@ -89,7 +89,7 @@ struct StartChatScreen: View { Section { ForEach(context.viewState.usersSection.users, id: \.userID) { user in Button { context.send(viewAction: .selectUser(user)) } label: { - UserProfileCell(user: user, imageProvider: context.imageProvider) + UserProfileCell(user: user, membership: nil, imageProvider: context.imageProvider) } .buttonStyle(FormButtonStyle()) } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 6a9fde107..407d2320c 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -493,7 +493,8 @@ class ClientProxy: ClientProxyProtocol { } private lazy var slidingSyncRequiredState = [RequiredState(key: "m.room.avatar", value: ""), - RequiredState(key: "m.room.encryption", value: "")] + RequiredState(key: "m.room.encryption", value: ""), + RequiredState(key: "m.room.power_levels", value: "")] private lazy var slidingSyncNotificationsRequiredState = [RequiredState(key: "m.room.member", value: "$ME"), RequiredState(key: "m.room.power_levels", value: ""), diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift index b7a30df12..32ae3cfb0 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift @@ -66,6 +66,10 @@ final class RoomMemberProxy: RoomMemberProxyProtocol { var isIgnored: Bool { member.isIgnored() } + + var canInviteUsers: Bool { + member.canInvite() + } func ignoreUser() async -> Result { sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true) diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index 511b58c17..8c791976a 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -33,6 +33,7 @@ protocol RoomMemberProxyProtocol { var normalizedPowerLevel: Int { get } var isAccountOwner: Bool { get } var isIgnored: Bool { get } + var canInviteUsers: Bool { get } func ignoreUser() async -> Result func unignoreUser() async -> Result diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index b3104973f..88276caf8 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -449,6 +449,18 @@ class RoomProxy: RoomProxyProtocol { } } + func invite(userID: String) async -> Result { + await Task.dispatch(on: .global()) { + do { + MXLog.info("Inviting user \(userID)") + return try .success(self.room.inviteUserById(userId: userID)) + } catch { + MXLog.error("Failed inviting user \(userID) with error: \(error)") + return .failure(.failedInvitingUser) + } + } + } + var invitedMembersCount: UInt { UInt(room.invitedMembersCount()) } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index e903597ba..bb5a7daca 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -35,6 +35,7 @@ enum RoomProxyError: Error { case failedLeavingRoom case failedAcceptingInvite case failedRejectingInvite + case failedInvitingUser } @MainActor @@ -105,6 +106,8 @@ protocol RoomProxyProtocol { func fetchDetails(for eventID: String) + func invite(userID: String) async -> Result + var invitedMembersCount: UInt { get } var joinedMembersCount: UInt { get } diff --git a/ElementX/Sources/Services/RoomMember/RoomMemberDetails.swift b/ElementX/Sources/Services/RoomMember/RoomMemberDetails.swift index e6b5369a5..6f45634f6 100644 --- a/ElementX/Sources/Services/RoomMember/RoomMemberDetails.swift +++ b/ElementX/Sources/Services/RoomMember/RoomMemberDetails.swift @@ -26,7 +26,7 @@ struct RoomMemberDetails: Identifiable, Equatable { init(withProxy proxy: RoomMemberProxyProtocol) { id = proxy.userID - name = proxy.displayName + name = proxy.displayName ?? proxy.userID avatarURL = proxy.avatarURL permalink = proxy.permalink isAccountOwner = proxy.isAccountOwner diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 911dff0e8..2597d62fa 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -203,7 +203,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { roomProxy: roomProxy, timelineController: timelineController, mediaProvider: userSession.mediaProvider, - emojiProvider: emojiProvider) + emojiProvider: emojiProvider, + userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy)) let coordinator = RoomScreenCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in switch action { @@ -262,7 +263,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator, roomProxy: roomProxy, - mediaProvider: userSession.mediaProvider) + mediaProvider: userSession.mediaProvider, + userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy)) let coordinator = RoomDetailsScreenCoordinator(parameters: params) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index c08b163bc..78e172ee4 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -162,7 +162,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -172,7 +173,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -184,7 +186,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -199,7 +202,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -214,7 +218,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -229,7 +234,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -245,7 +251,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -260,7 +267,8 @@ class MockScreen: Identifiable { roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userDiscoveryService: UserDiscoveryServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -293,7 +301,8 @@ class MockScreen: Identifiable { members: members)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, - mediaProvider: MockMediaProvider())) + mediaProvider: MockMediaProvider(), + userDiscoveryService: UserDiscoveryServiceMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomDetailsScreenWithRoomAvatar: @@ -308,7 +317,22 @@ class MockScreen: Identifiable { members: members)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, - mediaProvider: MockMediaProvider())) + mediaProvider: MockMediaProvider(), + userDiscoveryService: UserDiscoveryServiceMock())) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator + case .roomDetailsScreenWithInvite: + ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled = true + let navigationStackCoordinator = NavigationStackCoordinator() + let members: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockCharlie] + let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier", + displayName: "Room", + isEncrypted: true, + members: members)) + let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, + roomProxy: roomProxy, + mediaProvider: MockMediaProvider(), + userDiscoveryService: UserDiscoveryServiceMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomMembersListScreen: @@ -381,7 +405,8 @@ class MockScreen: Identifiable { members: members)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, - mediaProvider: MockMediaProvider())) + mediaProvider: MockMediaProvider(), + userDiscoveryService: UserDiscoveryServiceMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .invitesWithBadges: @@ -414,15 +439,17 @@ class MockScreen: Identifiable { let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator - case .inviteUsers: + case .inviteUsers, .inviteUsersInRoom, .inviteUsersInRoomExistingMembers: ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = true let navigationStackCoordinator = NavigationStackCoordinator() let userDiscoveryMock = UserDiscoveryServiceMock() userDiscoveryMock.fetchSuggestionsReturnValue = .success([.mockAlice, .mockBob, .mockCharlie]) userDiscoveryMock.searchProfilesWithReturnValue = .success([]) - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()) + let mediaProvider = MockMediaProvider() let usersSubject = CurrentValueSubject<[UserProfile], Never>([]) - let coordinator = InviteUsersScreenCoordinator(parameters: .init(userSession: userSession, userDiscoveryService: userDiscoveryMock, selectedUsers: usersSubject.asCurrentValuePublisher())) + let members: [RoomMemberProxyMock] = id == .inviteUsersInRoomExistingMembers ? [.mockInvitedAlice, .mockBob] : [] + let roomType: InviteUsersScreenRoomType = id == .inviteUsers ? .draft : .room(members: members, userIndicatorController: MockUserIndicatorController()) + let coordinator = InviteUsersScreenCoordinator(parameters: .init(selectedUsers: usersSubject.asCurrentValuePublisher(), roomType: roomType, mediaProvider: mediaProvider, userDiscoveryService: userDiscoveryMock)) coordinator.actions.sink { action in switch action { case .toggleUser(let user): diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 621f39f69..e1013ae4d 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -43,6 +43,7 @@ enum UITestsScreenIdentifier: String { case userSessionScreen case roomDetailsScreen case roomDetailsScreenWithRoomAvatar + case roomDetailsScreenWithInvite case roomDetailsScreenDmDetails case roomMembersListScreen case roomMembersListScreenPendingInvites @@ -56,6 +57,8 @@ enum UITestsScreenIdentifier: String { case invitesWithBadges case invitesNoInvites case inviteUsers + case inviteUsersInRoom + case inviteUsersInRoomExistingMembers case createRoom case createRoomNoUsers } diff --git a/UITests/Sources/InviteUsersScreenUITests.swift b/UITests/Sources/InviteUsersScreenUITests.swift index a3b5b7435..16a1cf76d 100644 --- a/UITests/Sources/InviteUsersScreenUITests.swift +++ b/UITests/Sources/InviteUsersScreenUITests.swift @@ -29,4 +29,16 @@ class InviteUsersScreenUITests: XCTestCase { app.collectionViews.firstMatch.cells.element(boundBy: 1).tap() try await app.assertScreenshot(.inviteUsers, step: 1) } + + func testInviteUsers() async throws { + let app = Application.launch(.inviteUsersInRoom) + app.collectionViews.firstMatch.cells.element(boundBy: 1).tap() + try await app.assertScreenshot(.inviteUsersInRoom, step: 1) + } + + func testInviteUserExistingMembers() async throws { + let app = Application.launch(.inviteUsersInRoomExistingMembers) + app.collectionViews.firstMatch.cells.element(boundBy: 1).tap() + try await app.assertScreenshot(.inviteUsersInRoomExistingMembers, step: 1) + } } diff --git a/UITests/Sources/RoomDetailsScreenUITests.swift b/UITests/Sources/RoomDetailsScreenUITests.swift index 03d135938..2a2d83368 100644 --- a/UITests/Sources/RoomDetailsScreenUITests.swift +++ b/UITests/Sources/RoomDetailsScreenUITests.swift @@ -34,6 +34,13 @@ class RoomDetailsScreenUITests: XCTestCase { XCTAssert(app.buttons[A11yIdentifiers.roomDetailsScreen.people].waitForExistence(timeout: 1)) try await app.assertScreenshot(.roomDetailsScreenWithRoomAvatar) } + + func testInitialStateComponentsWithInvite() async throws { + let app = Application.launch(.roomDetailsScreenWithInvite) + + XCTAssert(app.buttons[A11yIdentifiers.roomDetailsScreen.invite].waitForExistence(timeout: 1)) + try await app.assertScreenshot(.roomDetailsScreenWithInvite) + } func testInitialStateComponentsDmDetails() async throws { let app = Application.launch(.roomDetailsScreenDmDetails) diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersExistingRoom-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersExistingRoom-1.png new file mode 100644 index 000000000..ccd453973 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersExistingRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5998aef45c8d50471c13c400b07f172f2c47760f1896cdc1d26d73a7bdcd6e1e +size 108653 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoom-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoom-1.png new file mode 100644 index 000000000..ccd453973 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5998aef45c8d50471c13c400b07f172f2c47760f1896cdc1d26d73a7bdcd6e1e +size 108653 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png new file mode 100644 index 000000000..7cb87c5c3 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6047011ae61868f6d4ddce792dcdc41ed98ea22f277823de9e65a7f3336481d5 +size 97139 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithInvite.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithInvite.png new file mode 100644 index 000000000..06e6e012d --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithInvite.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9afaa98b21ab14e513ef00a5826891c34edf424271ea0ef736353967120d1467 +size 97265 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersExistingRoom-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersExistingRoom-1.png new file mode 100644 index 000000000..93530fc0b --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersExistingRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a2540eb953c411f815d3eea12b559fe5cc7632af644b841f1487d5379d0efc3 +size 133083 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoom-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoom-1.png new file mode 100644 index 000000000..93530fc0b --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a2540eb953c411f815d3eea12b559fe5cc7632af644b841f1487d5379d0efc3 +size 133083 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoomExistingMembers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoomExistingMembers-1.png new file mode 100644 index 000000000..bff51661c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsersInRoomExistingMembers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0e8a9421366b6cfc72443ab9d7ee96ffe0b81ada3cd7ed48b911fae4cccf845 +size 112719 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithInvite.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithInvite.png new file mode 100644 index 000000000..2bb7c4311 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithInvite.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03ac9acfa2dd004be4389c02b6760d35fcdff0bc75a3a3d67e820ab719a2d0f0 +size 120758 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersExistingRoom-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersExistingRoom-1.png new file mode 100644 index 000000000..324a170b5 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersExistingRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:871a7cdcab72ffd00146d252eac9eb00309158af303457ca260bf194741d0fd7 +size 111420 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoom-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoom-1.png new file mode 100644 index 000000000..324a170b5 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:871a7cdcab72ffd00146d252eac9eb00309158af303457ca260bf194741d0fd7 +size 111420 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png new file mode 100644 index 000000000..38c448ab4 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsersInRoomExistingMembers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b14407efd99162bdd2ad4b859896cdf11b8bf98fc3522e85ae399e909f39c22 +size 103057 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithInvite.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithInvite.png new file mode 100644 index 000000000..c848cc3f7 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithInvite.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9e75b96549882fdc8e154c6782d855e9c761439db9647e4a60f3db3475b7d89 +size 111269 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersExistingRoom-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersExistingRoom-1.png new file mode 100644 index 000000000..db4199507 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersExistingRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f4003754398e766a8190e9856da6905adccd84cda9b2733a80d66cb1e7b9258 +size 136980 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoom-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoom-1.png new file mode 100644 index 000000000..db4199507 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoom-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f4003754398e766a8190e9856da6905adccd84cda9b2733a80d66cb1e7b9258 +size 136980 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoomExistingMembers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoomExistingMembers-1.png new file mode 100644 index 000000000..c9839013d --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsersInRoomExistingMembers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe3a4bfcb6ea5b31f8640df8fc8aa5eecc197a6e314d296f3636f26a155aaba4 +size 117935 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithInvite.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithInvite.png new file mode 100644 index 000000000..5aad8cc16 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithInvite.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e95327be10fc4925a6d43045d91cda86a1898fa745a4117573cfc4add17ebc3 +size 150407 diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index f50319a4a..2aa247c81 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -25,26 +25,73 @@ class InviteUsersScreenViewModelTests: XCTestCase { var clientProxy: MockClientProxy! var userDiscoveryService: UserDiscoveryServiceMock! - private let usersSubject = CurrentValueSubject<[UserProfile], Never>([]) private var cancellables: Set = [] var context: InviteUsersScreenViewModel.Context { viewModel.context } - override func setUpWithError() throws { + func testSelectUser() { + setupWithRoomType(roomType: .draft) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .toggleUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.count == 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + } + + func testReselectUser() { + setupWithRoomType(roomType: .draft) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .toggleUser(.mockAlice)) + XCTAssertEqual(context.viewState.selectedUsers.count, 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + context.send(viewAction: .toggleUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + } + + func testDeselectUser() { + setupWithRoomType(roomType: .draft) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .toggleUser(.mockAlice)) + XCTAssertEqual(context.viewState.selectedUsers.count, 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + context.send(viewAction: .toggleUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + } + + func testInviteButton() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] + setupWithRoomType(roomType: .room(members: mockedMembers, userIndicatorController: MockUserIndicatorController())) + _ = await viewModel.context.$viewState.values.first(where: { $0.membershipState.isEmpty == false }) + context.send(viewAction: .toggleUser(.mockAlice)) + + Task { + await Task.yield() + context.send(viewAction: .proceed) + } + + let action = await viewModel.actions.values.first() + + guard case let .invite(members) = action else { + XCTFail("Sent action should be 'invite'") + return + } + + XCTAssertEqual(members, [RoomMemberProxyMock.mockAlice.userID]) + } + + private func setupWithRoomType(roomType: InviteUsersScreenRoomType) { + let usersSubject = CurrentValueSubject<[UserProfile], Never>([]) clientProxy = .init(userID: "") userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.fetchSuggestionsReturnValue = .success([]) userDiscoveryService.searchProfilesWithReturnValue = .success([]) - let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) usersSubject.send([]) - let viewModel = InviteUsersScreenViewModel(selectedUsers: usersSubject.asCurrentValuePublisher(), userSession: userSession, userDiscoveryService: userDiscoveryService) + let viewModel = InviteUsersScreenViewModel(selectedUsers: usersSubject.asCurrentValuePublisher(), roomType: roomType, mediaProvider: MockMediaProvider(), userDiscoveryService: userDiscoveryService) viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) self.viewModel = viewModel - viewModel.actions.sink { [weak self] action in - guard let self else { return } + viewModel.actions.sink { action in switch action { case .toggleUser(let user): var selectedUsers = usersSubject.value @@ -60,29 +107,4 @@ class InviteUsersScreenViewModelTests: XCTestCase { } .store(in: &cancellables) } - - func testSelectUser() { - XCTAssertTrue(context.viewState.selectedUsers.isEmpty) - context.send(viewAction: .toggleUser(.mockAlice)) - XCTAssertTrue(context.viewState.selectedUsers.count == 1) - XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) - } - - func testReselectUser() { - XCTAssertTrue(context.viewState.selectedUsers.isEmpty) - context.send(viewAction: .toggleUser(.mockAlice)) - XCTAssertEqual(context.viewState.selectedUsers.count, 1) - XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) - context.send(viewAction: .toggleUser(.mockAlice)) - XCTAssertTrue(context.viewState.selectedUsers.isEmpty) - } - - func testDeselectUser() { - XCTAssertTrue(context.viewState.selectedUsers.isEmpty) - context.send(viewAction: .toggleUser(.mockAlice)) - XCTAssertEqual(context.viewState.selectedUsers.count, 1) - XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) - context.send(viewAction: .toggleUser(.mockAlice)) - XCTAssertTrue(context.viewState.selectedUsers.isEmpty) - } } diff --git a/UnitTests/Sources/RoomDetailsViewModelTests.swift b/UnitTests/Sources/RoomDetailsViewModelTests.swift index 43f10811c..70b371f40 100644 --- a/UnitTests/Sources/RoomDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsViewModelTests.swift @@ -180,4 +180,38 @@ class RoomDetailsScreenViewModelTests: XCTestCase { XCTAssert(context.viewState.dmRecipient?.isIgnored == true) XCTAssertNotNil(context.alertInfo) } + + func testCannotInvitePeople() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: true, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + await context.nextViewState() + + XCTAssertFalse(context.viewState.canInviteUsers) + } + + func testInvitePeople() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: true, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + await context.nextViewState() + + XCTAssertTrue(context.viewState.canInviteUsers) + + var callbackCorrectlyCalled = false + viewModel.callback = { action in + switch action { + case .requestInvitePeoplePresentation(let members): + callbackCorrectlyCalled = members.map(\.userID) == mockedMembers.map(\.userID) + default: + callbackCorrectlyCalled = false + } + } + + context.send(viewAction: .processTapInvite) + await Task.yield() + XCTAssertTrue(callbackCorrectlyCalled) + } }