From 940801f400da196046b002111dad9604fa1f65a3 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:41:19 +0100 Subject: [PATCH] Add support for joining rooms from a space. (#4501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for joining rooms from a space. Doesn't yet handle the Join button 🤔 * Handle the join button for both rooms and spaces. Also refactor more instances of spaceRoom to spaceRoomProxy. --- .../SpaceFlowCoordinator.swift | 2 +- ElementX/Sources/Mocks/ClientProxyMock.swift | 16 +++- .../Mocks/Generated/GeneratedMocks.swift | 70 +++++++++++++++++ .../Sources/Mocks/RoomPreviewProxyMock.swift | 14 ++++ .../Sources/Mocks/SpaceServiceProxyMock.swift | 4 +- .../JoinRoomScreenViewModel.swift | 5 +- .../JoinRoomScreen/View/JoinRoomScreen.swift | 10 ++- .../Screens/Spaces/Common/SpaceRoomCell.swift | 16 ++++ .../SpaceListScreenViewModel.swift | 2 +- .../View/SpaceListScreen.swift | 6 +- .../SpaceScreen/SpaceScreenCoordinator.swift | 8 +- .../SpaceScreen/SpaceScreenModels.swift | 1 + .../SpaceScreen/SpaceScreenViewModel.swift | 39 ++++++++-- .../SpaceScreenViewModelProtocol.swift | 2 + .../Spaces/SpaceScreen/View/SpaceScreen.swift | 3 +- .../Sources/Services/Client/ClientProxy.swift | 11 +++ .../Services/Client/ClientProxyProtocol.swift | 2 + .../UITests/UITestsAppCoordinator.swift | 3 +- ...omScreen.RestrictedJoinable-iPad-en-GB.png | 3 + ...mScreen.RestrictedJoinable-iPad-pseudo.png | 3 + ...een.RestrictedJoinable-iPhone-16-en-GB.png | 3 + ...en.RestrictedJoinable-iPhone-16-pseudo.png | 3 + .../spaceRoomCell.iPad-en-GB-0.png | 4 +- .../spaceRoomCell.iPad-pseudo-0.png | 4 +- .../spaceRoomCell.iPhone-16-en-GB-0.png | 4 +- .../spaceRoomCell.iPhone-16-pseudo-0.png | 4 +- UITests/Sources/UserSessionScreenTests.swift | 13 ++++ ...testSpaceExploration-iPad-18-5-en-GB-9.png | 3 + ...stSpaceExploration-iPhone-18-5-en-GB-9.png | 3 + .../Sources/SpaceScreenViewModelTests.swift | 77 ++++++++++++++++++- 30 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-pseudo.png create mode 100644 UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPad-18-5-en-GB-9.png create mode 100644 UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPhone-18-5-en-GB-9.png diff --git a/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift index a60aa0093..48f76cf1b 100644 --- a/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift @@ -158,7 +158,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { let parameters = SpaceScreenCoordinatorParameters(spaceRoomListProxy: spaceRoomListProxy, spaceServiceProxy: spaceServiceProxy, selectedSpaceRoomPublisher: selectedSpaceRoomSubject.asCurrentValuePublisher(), - mediaProvider: flowParameters.userSession.mediaProvider, + userSession: flowParameters.userSession, userIndicatorController: flowParameters.userIndicatorController) let coordinator = SpaceScreenCoordinator(parameters: parameters) coordinator.actionsPublisher diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index c180dea37..5c0e18966 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -15,6 +15,7 @@ struct ClientProxyMockConfiguration { var deviceID: String? var roomSummaryProvider: RoomSummaryProviderProtocol = RoomSummaryProviderMock(.init()) var joinedSpaceRooms: [SpaceRoomProxyProtocol] = [] + var roomPreviews: [RoomPreviewProxyProtocol]? var roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol? var recoveryState: SecureBackupRecoveryState = .enabled @@ -65,6 +66,7 @@ extension ClientProxyMock { directRoomForUserIDReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) createDirectRoomWithExpectedRoomNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) + canJoinRoomWithReturnValue = true uploadMediaReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) loadUserDisplayNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) setUserDisplayNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) @@ -95,13 +97,23 @@ extension ClientProxyMock { roomForIdentifierClosure = { [weak self] identifier in if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) { await .joined(JoinedRoomProxyMock(.init(id: room.id, name: room.name))) - } else if let spaceRoom = configuration.joinedSpaceRooms.first(where: { $0.id == identifier }) { - await .joined(JoinedRoomProxyMock(.init(id: spaceRoom.id, name: spaceRoom.name))) + } else if let spaceRoomProxy = configuration.joinedSpaceRooms.first(where: { $0.id == identifier }) { + await .joined(JoinedRoomProxyMock(.init(id: spaceRoomProxy.id, name: spaceRoomProxy.name))) } else { nil } } + if let roomPreviews = configuration.roomPreviews { + roomPreviewForIdentifierViaClosure = { roomID, _ in + if let preview = roomPreviews.first(where: { $0.info.id == roomID }) { + .success(preview) + } else { + .failure(.roomPreviewIsPrivate) + } + } + } + userIdentityForReturnValue = .success(UserIdentityProxyMock(configuration: .init())) underlyingIsReportRoomSupported = true diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 44726a082..34f27b711 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2987,6 +2987,76 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { return knockRoomAliasMessageReturnValue } } + //MARK: - canJoinRoom + + var canJoinRoomWithUnderlyingCallsCount = 0 + var canJoinRoomWithCallsCount: Int { + get { + if Thread.isMainThread { + return canJoinRoomWithUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = canJoinRoomWithUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canJoinRoomWithUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + canJoinRoomWithUnderlyingCallsCount = newValue + } + } + } + } + var canJoinRoomWithCalled: Bool { + return canJoinRoomWithCallsCount > 0 + } + var canJoinRoomWithReceivedRules: [AllowRule]? + var canJoinRoomWithReceivedInvocations: [[AllowRule]] = [] + + var canJoinRoomWithUnderlyingReturnValue: Bool! + var canJoinRoomWithReturnValue: Bool! { + get { + if Thread.isMainThread { + return canJoinRoomWithUnderlyingReturnValue + } else { + var returnValue: Bool? = nil + DispatchQueue.main.sync { + returnValue = canJoinRoomWithUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canJoinRoomWithUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + canJoinRoomWithUnderlyingReturnValue = newValue + } + } + } + } + var canJoinRoomWithClosure: (([AllowRule]) -> Bool)? + + func canJoinRoom(with rules: [AllowRule]) -> Bool { + canJoinRoomWithCallsCount += 1 + canJoinRoomWithReceivedRules = rules + DispatchQueue.main.async { + self.canJoinRoomWithReceivedInvocations.append(rules) + } + if let canJoinRoomWithClosure = canJoinRoomWithClosure { + return canJoinRoomWithClosure(rules) + } else { + return canJoinRoomWithReturnValue + } + } //MARK: - uploadMedia var uploadMediaUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Mocks/RoomPreviewProxyMock.swift b/ElementX/Sources/Mocks/RoomPreviewProxyMock.swift index cc6463c6c..370e93415 100644 --- a/ElementX/Sources/Mocks/RoomPreviewProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomPreviewProxyMock.swift @@ -96,4 +96,18 @@ extension RoomPreviewProxyMock { underlyingOwnMembershipDetails = roomMembershipDetails } + + convenience init(spaceRoomProxy: SpaceRoomProxyProtocol) { + self.init(Configuration(roomID: spaceRoomProxy.id, + canonicalAlias: spaceRoomProxy.canonicalAlias ?? "", + name: spaceRoomProxy.name ?? "", + topic: spaceRoomProxy.topic ?? "", + avatarURL: spaceRoomProxy.avatarURL?.absoluteString ?? "", + numJoinedMembers: UInt64(spaceRoomProxy.joinedMembersCount), + numActiveMembers: UInt64(spaceRoomProxy.joinedMembersCount), + roomType: spaceRoomProxy.isSpace ? .space : .room, + membership: nil, + joinRule: spaceRoomProxy.joinRule ?? .restricted(rules: []), + isDirect: false)) + } } diff --git a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift index aceaaa075..423a9b0e6 100644 --- a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift +++ b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift @@ -19,8 +19,8 @@ extension SpaceServiceProxyMock { self.init() joinedSpacesPublisher = .init(configuration.joinedSpaces) - spaceRoomListForClosure = { spaceRoom in - if let spaceRoomList = configuration.spaceRoomLists[spaceRoom.id] { + spaceRoomListForClosure = { spaceRoomProxy in + if let spaceRoomList = configuration.spaceRoomLists[spaceRoomProxy.id] { .success(spaceRoomList) } else { .failure(.sdkError(ClientProxyMockError.generic)) diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift index caca67727..0e0ef4a5f 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift @@ -6,6 +6,7 @@ // import Combine +import MatrixRustSDK import SwiftUI typealias JoinRoomScreenViewModelType = StateStoreViewModel @@ -211,8 +212,8 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo state.mode = .inviteRequired case .knock, .knockRestricted: state.mode = appSettings.knockingEnabled ? .knockable : .joinable - case .restricted: - state.mode = .restricted + case .restricted(let rules): + state.mode = clientProxy.canJoinRoom(with: rules) ? .joinable : .restricted default: state.mode = .joinable } diff --git a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift index e606aa132..0e218f93d 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift @@ -306,7 +306,8 @@ struct JoinRoomScreen: View { struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview { static let unknownViewModel = makeViewModel(mode: .unknown) static let joinableViewModel = makeViewModel(mode: .joinable) - static let restrictedViewModel = makeViewModel(mode: .restricted) + static let restrictedViewModel = makeViewModel(mode: .restricted, canJoinRoom: false) + static let restrictedJoinableViewModel = makeViewModel(mode: .restricted) static let inviteRequiredViewModel = makeViewModel(mode: .inviteRequired) static let invitedViewModel = makeViewModel(mode: .invited(isDM: false)) static let invitedDMViewModel = makeViewModel(mode: .invited(isDM: true)) @@ -321,6 +322,8 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview { makePreview(viewModel: unknownViewModel, mode: .unknown) makePreview(viewModel: joinableViewModel, mode: .joinable) makePreview(viewModel: restrictedViewModel, mode: .restricted) + makePreview(viewModel: restrictedJoinableViewModel, mode: .restricted, + customPreviewName: "RestrictedJoinable") makePreview(viewModel: inviteRequiredViewModel, mode: .inviteRequired) makePreview(viewModel: invitedViewModel, mode: .invited(isDM: false)) makePreview(viewModel: invitedDMViewModel, mode: .invited(isDM: true)) @@ -359,11 +362,14 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview { } } - static func makeViewModel(mode: JoinRoomScreenMode, hideInviteAvatars: Bool = false) -> JoinRoomScreenViewModel { + static func makeViewModel(mode: JoinRoomScreenMode, + canJoinRoom: Bool = true, + hideInviteAvatars: Bool = false) -> JoinRoomScreenViewModel { let appSettings = AppSettings() appSettings.knockingEnabled = true let clientProxy = ClientProxyMock(.init(hideInviteAvatars: hideInviteAvatars)) + clientProxy.canJoinRoomWithReturnValue = canJoinRoom switch mode { case .unknown: diff --git a/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift b/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift index 402e1ab78..a4f78594a 100644 --- a/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift +++ b/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift @@ -14,6 +14,7 @@ struct SpaceRoomCell: View { let spaceRoomProxy: SpaceRoomProxyProtocol let isSelected: Bool + var isJoining = false let mediaProvider: MediaProviderProtocol! enum Action { case select(SpaceRoomProxyProtocol), join(SpaceRoomProxyProtocol) } @@ -116,6 +117,12 @@ struct SpaceRoomCell: View { Button(L10n.actionJoin) { action(.join(spaceRoomProxy)) } .font(.compound.bodyLG) .foregroundStyle(.compound.textActionAccent) + .opacity(isJoining ? 0 : 1) + .overlay { + if isJoining { + ProgressView() + } + } case .joined, .knocked, .banned: EmptyView() } @@ -145,6 +152,15 @@ struct SpaceRoomCell_Previews: PreviewProvider, TestablePreview { isSelected: false, mediaProvider: mediaProvider) { _ in } } + + SpaceRoomCell(spaceRoomProxy: SpaceRoomProxyMock(.init(id: "Space being joined", isSpace: true)), + isSelected: false, + isJoining: true, + mediaProvider: mediaProvider) { _ in } + SpaceRoomCell(spaceRoomProxy: SpaceRoomProxyMock(.init(id: "Room being joined", isSpace: false)), + isSelected: false, + isJoining: true, + mediaProvider: mediaProvider) { _ in } } } } diff --git a/ElementX/Sources/Screens/Spaces/SpaceListScreen/SpaceListScreenViewModel.swift b/ElementX/Sources/Screens/Spaces/SpaceListScreen/SpaceListScreenViewModel.swift index 74806716f..a5d265a77 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceListScreen/SpaceListScreenViewModel.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceListScreen/SpaceListScreenViewModel.swift @@ -58,7 +58,7 @@ class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenVie switch viewAction { case .spaceAction(.select(let spaceRoomProxy)): Task { await selectSpace(spaceRoomProxy) } - case .spaceAction(.join(let spaceRoom)): + case .spaceAction(.join(let spaceRoomProxy)): #warning("Implement joining.") case .showSettings: actionsSubject.send(.showSettings) diff --git a/ElementX/Sources/Screens/Spaces/SpaceListScreen/View/SpaceListScreen.swift b/ElementX/Sources/Screens/Spaces/SpaceListScreen/View/SpaceListScreen.swift index ac5425c25..71a16c23e 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceListScreen/View/SpaceListScreen.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceListScreen/View/SpaceListScreen.swift @@ -58,9 +58,9 @@ struct SpaceListScreen: View { } var spaces: some View { - ForEach(context.viewState.joinedSpaces, id: \.id) { spaceRoom in - SpaceRoomCell(spaceRoomProxy: spaceRoom, - isSelected: spaceRoom.id == context.viewState.selectedSpaceID, + ForEach(context.viewState.joinedSpaces, id: \.id) { spaceRoomProxy in + SpaceRoomCell(spaceRoomProxy: spaceRoomProxy, + isSelected: spaceRoomProxy.id == context.viewState.selectedSpaceID, mediaProvider: context.mediaProvider) { action in context.send(viewAction: .spaceAction(action)) } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift index a1b1ea963..d8310a3ca 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift @@ -14,7 +14,7 @@ struct SpaceScreenCoordinatorParameters { let spaceRoomListProxy: SpaceRoomListProxyProtocol let spaceServiceProxy: SpaceServiceProxyProtocol let selectedSpaceRoomPublisher: CurrentValuePublisher - let mediaProvider: MediaProviderProtocol + let userSession: UserSessionProtocol let userIndicatorController: UserIndicatorControllerProtocol } @@ -40,7 +40,7 @@ final class SpaceScreenCoordinator: CoordinatorProtocol { viewModel = SpaceScreenViewModel(spaceRoomListProxy: parameters.spaceRoomListProxy, spaceServiceProxy: parameters.spaceServiceProxy, selectedSpaceRoomPublisher: parameters.selectedSpaceRoomPublisher, - mediaProvider: parameters.mediaProvider, + userSession: parameters.userSession, userIndicatorController: parameters.userIndicatorController) } @@ -58,6 +58,10 @@ final class SpaceScreenCoordinator: CoordinatorProtocol { } .store(in: &cancellables) } + + func stop() { + viewModel.stop() + } func toPresentable() -> AnyView { AnyView(SpaceScreen(context: viewModel.context)) diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift index 24e4c1d58..002f0d5e1 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift @@ -18,6 +18,7 @@ struct SpaceScreenViewState: BindableState { var isPaginating = false var rooms: [SpaceRoomProxyProtocol] var selectedSpaceRoomID: String? + var joiningRoomIDs: Set = [] var bindings = SpaceScreenViewStateBindings() diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift index 45500fba4..d0451c27a 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift @@ -12,6 +12,7 @@ typealias SpaceScreenViewModelType = StateStoreViewModelV2 = .init() @@ -22,15 +23,16 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc init(spaceRoomListProxy: SpaceRoomListProxyProtocol, spaceServiceProxy: SpaceServiceProxyProtocol, selectedSpaceRoomPublisher: CurrentValuePublisher, - mediaProvider: MediaProviderProtocol, + userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol) { self.spaceServiceProxy = spaceServiceProxy + clientProxy = userSession.clientProxy self.userIndicatorController = userIndicatorController super.init(initialViewState: SpaceScreenViewState(space: spaceRoomListProxy.spaceRoomProxy, rooms: spaceRoomListProxy.spaceRoomsPublisher.value, selectedSpaceRoomID: selectedSpaceRoomPublisher.value), - mediaProvider: mediaProvider) + mediaProvider: userSession.mediaProvider) spaceRoomListProxy.spaceRoomsPublisher .receive(on: DispatchQueue.main) @@ -67,16 +69,20 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc case .spaceAction(.select(let spaceRoomProxy)): if spaceRoomProxy.isSpace { Task { await selectSpace(spaceRoomProxy) } - } else if spaceRoomProxy.state == .joined { - // This probably doesn't need the state condition as the room flow will show a join screen, - // but we can allow this later, once we've updated the design to indicate the parent space. + } else { + // No need to check the join state, the room flow will show an appropriately configured join screen if needed. actionsSubject.send(.selectRoom(roomID: spaceRoomProxy.id)) } - case .spaceAction(.join(let spaceID)): - #warning("Implement joining.") + case .spaceAction(.join(let spaceRoomProxy)): + Task { await join(spaceRoomProxy) } } } + func stop() { + // If we pop this screen with running join operations, we don't want them to do anything. + state.joiningRoomIDs.removeAll() + } + // MARK: - Private private func selectSpace(_ spaceRoomProxy: SpaceRoomProxyProtocol) async { @@ -89,6 +95,25 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc } } + private func join(_ spaceRoomProxy: SpaceRoomProxyProtocol) async { + state.joiningRoomIDs.insert(spaceRoomProxy.id) + defer { state.joiningRoomIDs.remove(spaceRoomProxy.id) } + + guard case .success = await clientProxy.joinRoom(spaceRoomProxy.id, via: []) else { + showFailureIndicator() + return + } + + // If multiple join operations are running, then only show the last one. + guard state.joiningRoomIDs == [spaceRoomProxy.id] else { return } + + if spaceRoomProxy.isSpace { + await selectSpace(spaceRoomProxy) + } else { + actionsSubject.send(.selectRoom(roomID: spaceRoomProxy.id)) + } + } + // MARK: - Indicators private static var failureIndicatorID: String { "\(Self.self)-Failure" } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModelProtocol.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModelProtocol.swift index 3e8e9d7f2..a8a437793 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModelProtocol.swift @@ -11,4 +11,6 @@ import Combine protocol SpaceScreenViewModelProtocol { var actionsPublisher: AnyPublisher { get } var context: SpaceScreenViewModelType.Context { get } + + func stop() } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift index e3a87aa2c..8707fd5e3 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift @@ -30,6 +30,7 @@ struct SpaceScreen: View { ForEach(context.viewState.rooms, id: \.id) { spaceRoomProxy in SpaceRoomCell(spaceRoomProxy: spaceRoomProxy, isSelected: spaceRoomProxy.id == context.viewState.selectedSpaceRoomID, + isJoining: context.viewState.joiningRoomIDs.contains(spaceRoomProxy.id), mediaProvider: context.mediaProvider) { action in context.send(viewAction: .spaceAction(action)) } @@ -78,7 +79,7 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview { let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy, spaceServiceProxy: SpaceServiceProxyMock(.init()), selectedSpaceRoomPublisher: .init(nil), - mediaProvider: MediaProviderMock(configuration: .init()), + userSession: UserSessionMock(.init()), userIndicatorController: UserIndicatorControllerMock()) return viewModel } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index bee34afa5..a1ee456b8 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -542,6 +542,17 @@ class ClientProxy: ClientProxyProtocol { } } + func canJoinRoom(with rules: [AllowRule]) -> Bool { + for rule in rules { + if case let .roomMembership(roomID) = rule, + let room = try? client.getRoom(roomId: roomID), + room.membership() == .joined { + return true + } + } + return false + } + func uploadMedia(_ media: MediaInfo) async -> Result { guard let mimeType = media.mimeType else { MXLog.error("Failed uploading media, invalid mime type: \(media)") diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 2b21d823f..c7f765e43 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -165,6 +165,8 @@ protocol ClientProxyProtocol: AnyObject { func knockRoomAlias(_ roomAlias: String, message: String?) async -> Result + func canJoinRoom(with rules: [AllowRule]) -> Bool + func uploadMedia(_ media: MediaInfo) async -> Result func roomForIdentifier(_ identifier: String) async -> RoomProxyType? diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 12b487ab8..b13fedff6 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -574,7 +574,8 @@ class MockScreen: Identifiable { let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", deviceID: "MOCKCLIENT", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - joinedSpaceRooms: .mockSingleRoom)) + joinedSpaceRooms: .mockSingleRoom, + roomPreviews: [SpaceRoomProxyProtocol].mockSpaceList.map(RoomPreviewProxyMock.init))) // The tab bar remains hidden for the non-spaces tests as we don't supply any mock spaces. let spaceServiceProxy = SpaceServiceProxyMock(id == .userSessionSpacesFlow ? .populated : .init()) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-en-GB.png new file mode 100644 index 000000000..08e1a694d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27823508aef800ee5e601cecc5e96d9e76852504601b281292f7476d1f74a8eb +size 187598 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-pseudo.png new file mode 100644 index 000000000..d55111f70 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f56f3de7ee2b2e4973011281cb965e4e1056ea99e42c6cbc9be61f8eff002d69 +size 189152 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-en-GB.png new file mode 100644 index 000000000..5339d1bcc --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eac77fde80089ddbaf7b70355f3d8801ffe3f08e53112d554e47abf3098a3774 +size 142611 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-pseudo.png new file mode 100644 index 000000000..a26248781 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/joinRoomScreen.RestrictedJoinable-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2309efb8379f8a32c999420d6e9aa3303b73980fc29e3b1a056420f35225642b +size 144100 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png index 886968f76..3f0c2b8c0 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e2b6b775f3a198e0a52991a296bc1ad5756af00b67c3e867eac90313e62513a -size 207919 +oid sha256:54368559609ff14224342f399d05e2a5dbd2a8955aaf4154d53b6f0ac356dd3a +size 237461 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png index 00aa449fb..13cf6ae6f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f95f1258a7467c48a260c4d9968822f7e4a68f07e664c115f9d6a127c1cd2cf6 -size 237067 +oid sha256:074fc2ca164c28f15ab817ebee9f8ec74eb1cb99737996777e03b65873935e5e +size 278733 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-en-GB-0.png index 6c7c3458b..78a176bce 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:104cfb4b0707e035efd03a941da0da8a6c01d189e8d1b66ec1ed4ef1e245062c -size 149502 +oid sha256:37ff651fc4d7b3e559df8148424d5f58935aa885fd51795db37e8ac687ceaf20 +size 173995 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-pseudo-0.png index 57b2e4241..874bc9c97 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-16-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4fb5143447b96770909c6a0daddb237e64a3df4e0a48b7aa0d3a60e18c7e586 -size 167301 +oid sha256:3e8846578b4730e1fb88c183078eb2be0fe707f528ec3ae9dd56a4720f5caa2a +size 197153 diff --git a/UITests/Sources/UserSessionScreenTests.swift b/UITests/Sources/UserSessionScreenTests.swift index a68fdd736..9dbb812ad 100644 --- a/UITests/Sources/UserSessionScreenTests.swift +++ b/UITests/Sources/UserSessionScreenTests.swift @@ -11,6 +11,7 @@ import XCTest class UserSessionScreenTests: XCTestCase { let firstRoomName = "Foundation 🔭🪐🌌" let firstSpaceName = "The Foundation" + let firstSpaceRoomName = "Company Room" let firstSubspaceName = "Company Space" let firstSubspaceRoomName = "Management" @@ -23,6 +24,7 @@ class UserSessionScreenTests: XCTestCase { static let spaceScreen = 6 static let subspaceScreen = 7 static let subspaceRoomScreen = 8 + static let spaceJoinRoomScreen = 9 } func testUserSessionFlows() async throws { @@ -94,5 +96,16 @@ class UserSessionScreenTests: XCTestCase { XCTAssert(app.staticTexts[firstSubspaceRoomName].waitForExistence(timeout: 5.0)) try await Task.sleep(for: .seconds(1)) try await app.assertScreenshot(step: Step.subspaceRoomScreen) + + app.navigationBars.buttons[firstSubspaceName].firstMatch.tap(.center) + XCTAssert(app.staticTexts[firstSubspaceName].waitForExistence(timeout: 5.0)) + + app.navigationBars.buttons[firstSpaceName].firstMatch.tap(.center) + XCTAssert(app.staticTexts[firstSpaceName].waitForExistence(timeout: 5.0)) + + app.buttons[A11yIdentifiers.spaceListScreen.spaceRoomName(firstSpaceRoomName)].tap() + XCTAssert(app.staticTexts[firstSpaceRoomName].waitForExistence(timeout: 5.0)) + try await Task.sleep(for: .seconds(1)) + try await app.assertScreenshot(step: Step.spaceJoinRoomScreen) } } diff --git a/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPad-18-5-en-GB-9.png b/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPad-18-5-en-GB-9.png new file mode 100644 index 000000000..cbeff5293 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPad-18-5-en-GB-9.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00f028c933e6d646b6e5ed7436e8b247a2509ea3d109ad512f104fc4107d8dc3 +size 270055 diff --git a/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPhone-18-5-en-GB-9.png b/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPhone-18-5-en-GB-9.png new file mode 100644 index 000000000..c6776e0bf --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/userSessionScreen.testSpaceExploration-iPhone-18-5-en-GB-9.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1e40fe58a1bcfb7ef3e4445921d38d856a3bfccdeb1ddff084f0a4af13e1d89 +size 98281 diff --git a/UnitTests/Sources/SpaceScreenViewModelTests.swift b/UnitTests/Sources/SpaceScreenViewModelTests.swift index 980436fba..357a6845f 100644 --- a/UnitTests/Sources/SpaceScreenViewModelTests.swift +++ b/UnitTests/Sources/SpaceScreenViewModelTests.swift @@ -15,6 +15,7 @@ import MatrixRustSDK class SpaceScreenViewModelTests: XCTestCase { var spaceRoomListProxy: SpaceRoomListProxyMock! let mockSpaceRooms = [SpaceRoomProxyProtocol].mockSpaceList + var clientProxy: ClientProxyMock! var paginationStateSubject: CurrentValueSubject = .init(.idle(endReached: true)) var viewModel: SpaceScreenViewModelProtocol! @@ -92,7 +93,7 @@ class SpaceScreenViewModelTests: XCTestCase { func testSelectingSpace() async throws { setupViewModel() - let selectedSpace = mockSpaceRooms[0] + let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace }, "There should be a space to select.") let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } viewModel.context.send(viewAction: .spaceAction(.select(selectedSpace))) let action = try await deferred.fulfill() @@ -105,6 +106,76 @@ class SpaceScreenViewModelTests: XCTestCase { } } + func testSelectingRoom() async throws { + setupViewModel() + + let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } + viewModel.context.send(viewAction: .spaceAction(.select(selectedRoom))) + let action = try await deferred.fulfill() + + switch action { + case .selectRoom(let roomID) where roomID == selectedRoom.id: + break + default: + XCTFail("The action should select the room.") + } + } + + func testJoiningSpace() async throws { + setupViewModel() + + let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace }, "There should be a space to select.") + + let expectation = XCTestExpectation(description: "Join room") + clientProxy.joinRoomViaClosure = { _, _ in + expectation.fulfill() + return .success(()) + } + let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } + let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedSpace.id], []]) + + viewModel.context.send(viewAction: .spaceAction(.join(selectedSpace))) + + await fulfillment(of: [expectation]) + try await deferredState.fulfill() + let action = try await deferred.fulfill() + + switch action { + case .selectSpace(let spaceRoomListProxy) where spaceRoomListProxy.spaceRoomProxy.id == selectedSpace.id: + break + default: + XCTFail("The join should finish by selecting the space.") + } + } + + func testJoiningRoom() async throws { + setupViewModel() + + let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + + let expectation = XCTestExpectation(description: "Join room") + clientProxy.joinRoomViaClosure = { _, _ in + expectation.fulfill() + return .success(()) + } + let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } + let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedRoom.id], []]) + + viewModel.context.send(viewAction: .spaceAction(.join(selectedRoom))) + + await fulfillment(of: [expectation]) + try await deferredState.fulfill() + let action = try await deferred.fulfill() + + switch action { + case .selectRoom(let roomID) where roomID == selectedRoom.id: + break + default: + XCTFail("The join should finish by selecting the room.") + } + } + // MARK: - Helpers private func setupViewModel(paginationResponses: [[SpaceRoomProxyProtocol]] = []) { @@ -115,10 +186,12 @@ class SpaceScreenViewModelTests: XCTestCase { let spaceServiceProxy = SpaceServiceProxyMock(.init()) spaceServiceProxy.spaceRoomListForClosure = { .success(SpaceRoomListProxyMock(.init(spaceRoomProxy: $0))) } + clientProxy = ClientProxyMock(.init()) + viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy, spaceServiceProxy: spaceServiceProxy, selectedSpaceRoomPublisher: .init(nil), - mediaProvider: MediaProviderMock(configuration: .init()), + userSession: UserSessionMock(.init(clientProxy: clientProxy)), userIndicatorController: UserIndicatorControllerMock()) } }