diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 4b992afdd..74b7c11c0 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -32,6 +32,7 @@ struct RoomProxyMockConfiguration { var alternativeAliases: [String] = [] var hasUnreadNotifications = Bool.random() var members: [RoomMemberProxyProtocol]? + var inviter: RoomMemberProxyMock? } extension RoomProxyMock { @@ -57,7 +58,12 @@ extension RoomProxyMock { } else { membersPublisher = Just([]).eraseToAnyPublisher() } + + if let inviter = configuration.inviter { + inviterClosure = { inviter } + } updateMembersClosure = { } + acceptInvitationClosure = { .success(()) } } } diff --git a/UnitTests/Sources/Extensions/AsyncSequence.swift b/ElementX/Sources/Other/Extensions/AsyncSequence.swift similarity index 100% rename from UnitTests/Sources/Extensions/AsyncSequence.swift rename to ElementX/Sources/Other/Extensions/AsyncSequence.swift diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index c4d5497ea..e28ae861d 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -26,6 +26,8 @@ struct HomeScreenCoordinatorParameters { enum HomeScreenCoordinatorAction { case presentRoom(roomIdentifier: String) + case presentRoomDetails(roomIdentifier: String) + case roomLeft(roomIdentifier: String) case presentSettingsScreen case presentFeedbackScreen case presentSessionVerificationScreen @@ -54,6 +56,10 @@ final class HomeScreenCoordinator: CoordinatorProtocol { switch action { case .presentRoom(let roomIdentifier): self.callback?(.presentRoom(roomIdentifier: roomIdentifier)) + case .presentRoomDetails(roomIdentifier: let roomIdentifier): + self.callback?(.presentRoomDetails(roomIdentifier: roomIdentifier)) + case .roomLeft(roomIdentifier: let roomIdentifier): + self.callback?(.roomLeft(roomIdentifier: roomIdentifier)) case .presentFeedbackScreen: self.callback?(.presentFeedbackScreen) case .presentSettingsScreen: diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index f81e565c9..258bcf934 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -20,6 +20,8 @@ import UIKit enum HomeScreenViewModelAction { case presentRoom(roomIdentifier: String) + case presentRoomDetails(roomIdentifier: String) + case roomLeft(roomIdentifier: String) case presentSessionVerificationScreen case presentSettingsScreen case presentFeedbackScreen @@ -36,6 +38,9 @@ enum HomeScreenViewUserMenuAction { enum HomeScreenViewAction { case selectRoom(roomIdentifier: String) + case showRoomDetails(roomIdentifier: String) + case leaveRoom(roomIdentifier: String) + case confirmLeaveRoom(roomIdentifier: String) case userMenu(action: HomeScreenViewUserMenuAction) case startChat case verifySession @@ -101,6 +106,7 @@ struct HomeScreenViewStateBindings { var searchQuery = "" var alertInfo: AlertInfo? + var leaveRoomAlertItem: LeaveRoomAlertItem? } struct HomeScreenRoom: Identifiable, Equatable { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 6ec4f8ed6..c2ec0e695 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -153,10 +153,17 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // MARK: - Public + // swiftlint:disable:next cyclomatic_complexity override func process(viewAction: HomeScreenViewAction) { switch viewAction { case .selectRoom(let roomIdentifier): callback?(.presentRoom(roomIdentifier: roomIdentifier)) + case .showRoomDetails(roomIdentifier: let roomIdentifier): + callback?(.presentRoomDetails(roomIdentifier: roomIdentifier)) + case .leaveRoom(roomIdentifier: let roomIdentifier): + startLeaveRoomProcess(roomId: roomIdentifier) + case .confirmLeaveRoom(roomIdentifier: let roomIdentifier): + leaveRoom(roomId: roomIdentifier) case .userMenu(let action): switch action { case .feedback: @@ -301,4 +308,56 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol visibleRoomsSummaryProvider.updateVisibleRange(range, timelineLimit: timelineLimit) } + + private static let leaveRoomLoadingID = "LeaveRoomLoading" + + private func startLeaveRoomProcess(roomId: String) { + Task { + defer { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID) + } + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true)) + + let room = await userSession.clientProxy.roomForIdentifier(roomId) + + guard let room else { + state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown) + return + } + + guard !room.isPublic else { + state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomId, state: .public) + return + } + + await room.updateMembers() + let joinedMembers = await room.membersPublisher.values.first()?.filter { $0.membership == .join } + + guard let joinedMembers else { + state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown) + return + } + + state.bindings.leaveRoomAlertItem = joinedMembers.count > 1 ? LeaveRoomAlertItem(roomId: roomId, state: .private) : LeaveRoomAlertItem(roomId: roomId, state: .empty) + } + } + + private func leaveRoom(roomId: String) { + Task { + defer { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID) + } + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true)) + + let room = await userSession.clientProxy.roomForIdentifier(roomId) + let result = await room?.leaveRoom() + + switch result { + case .none, .some(.failure): + state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown) + case .some(.success): + callback?(.roomLeft(roomIdentifier: roomId)) + } + } + } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index 5b027d473..94d18454a 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -59,6 +59,19 @@ struct HomeScreen: View { .redacted(reason: .placeholder) } else { HomeScreenRoomCell(room: room, context: context) + .contextMenu { + Button { + context.send(viewAction: .showRoomDetails(roomIdentifier: room.id)) + } label: { + Label(L10n.commonSettings, systemImage: "gearshape") + } + + Button(role: .destructive) { + context.send(viewAction: .leaveRoom(roomIdentifier: room.id)) + } label: { + Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right") + } + } } } } @@ -97,6 +110,9 @@ struct HomeScreen: View { .animation(.elementDefault, value: context.viewState.showSessionVerificationBanner) .animation(.elementDefault, value: context.viewState.roomListMode) .alert(item: $context.alertInfo) { $0.alert } + .alert(item: $context.leaveRoomAlertItem, + actions: leaveRoomAlertActions, + message: leaveRoomAlertMessage) .navigationTitle(L10n.screenRoomlistMainSpaceTitle) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -238,6 +254,18 @@ struct HomeScreen: View { // This will be deduped and throttled on the view model layer context.send(viewAction: .updateVisibleItemRange(range: lowerBound.. some View { + Button(item.cancelTitle, role: .cancel) { } + Button(item.confirmationTitle, role: .destructive) { + context.send(viewAction: .confirmLeaveRoom(roomIdentifier: item.roomId)) + } + } + + private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View { + Text(item.subtitle) + } } // MARK: - Previews diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift index 6a342c474..5d2624efd 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift @@ -106,6 +106,7 @@ struct LeaveRoomAlertItem: AlertItem { case `private` } + let roomId: String let state: RoomState let title = L10n.actionLeaveRoom let confirmationTitle = L10n.actionLeave diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index fa38d398a..3237afb44 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -75,11 +75,12 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr case .processTapPeople: callback?(.requestMemberDetailsPresentation(members)) case .processTapLeave: - guard members.count > 1 else { - state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: .empty) + let joinedMembers = members.filter { $0.membership == .join } + guard joinedMembers.count > 1 else { + state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: .empty) return } - state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: roomProxy.isPublic ? .public : .private) + state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: roomProxy.isPublic ? .public : .private) case .confirmLeave: Task { await leaveRoom() } case .processTapIgnore: diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 22a48c267..248d1f795 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -53,9 +53,13 @@ class MockClientProxy: ClientProxyProtocol { .failure(.failedCreatingRoom) } - var roomInviter: RoomMemberProxyMock? + var roomForIdentifierMocks: [String: RoomProxyMock] = .init() @MainActor func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { + guard roomForIdentifierMocks[identifier] == nil else { + return roomForIdentifierMocks[identifier] + } + guard let room = visibleRoomsSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else { return nil } @@ -64,10 +68,7 @@ class MockClientProxy: ClientProxyProtocol { case .empty: return RoomProxyMock(with: .init(displayName: "Empty room")) case .filled(let details), .invalidated(let details): - let room = RoomProxyMock(with: .init(displayName: details.name)) - room.acceptInvitationReturnValue = .success(()) - room.inviterReturnValue = roomInviter - return room + return RoomProxyMock(with: .init(displayName: details.name)) } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 05a7a2f54..d50c2dcef 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -123,9 +123,14 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { case (.invitesScreen, .closedInvitesScreen, .roomList): break case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId: - self.presentRoomWithIdentifier(roomId) + self.presentRoomWithIdentifier(roomId, animated: animated) case (.invitesScreen, .deselectRoom, .invitesScreen): break + + case (.roomList(let currentRoomId), .selectRoomDetails(let roomId), .roomList) where currentRoomId == roomId: + break + case (.roomList, .selectRoomDetails(let roomId), .roomList(let selectedRoomId)) where roomId == selectedRoomId: + self.presentRoomDetails(roomIdentifier: roomId, animated: animated) default: fatalError("Unknown transition: \(context)") @@ -152,6 +157,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { switch action { case .presentRoom(let roomIdentifier): self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier)) + case .presentRoomDetails(let roomIdentifier): + self.stateMachine.processEvent(.selectRoomDetails(roomId: roomIdentifier)) + case .roomLeft(let roomIdentifier): + self.deselectRoomIfNeeded(roomIdentifier: roomIdentifier) case .presentSettingsScreen: self.stateMachine.processEvent(.showSettingsScreen) case .presentFeedbackScreen: @@ -226,11 +235,54 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { } } } + + private func deselectRoomIfNeeded(roomIdentifier: String) { + guard + case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state, + selectedRoomId == roomIdentifier + else { + return + } + + stateMachine.processEvent(.deselectRoom) + navigationSplitCoordinator.setDetailCoordinator(nil) + } private func dismissRoom() { detailNavigationStackCoordinator.popToRoot(animated: true) navigationSplitCoordinator.setDetailCoordinator(nil) } + + private func presentRoomDetails(roomIdentifier: String, animated: Bool = true) { + Task { + guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else { + MXLog.error("Invalid room identifier: \(roomIdentifier)") + return + } + + let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator, + roomProxy: roomProxy, + mediaProvider: userSession.mediaProvider) + + let coordinator = RoomDetailsScreenCoordinator(parameters: params) + + coordinator.callback = { [weak self] action in + switch action { + case .cancel, .leftRoom: + self?.stateMachine.processEvent(.deselectRoom) + self?.detailNavigationStackCoordinator.setRootCoordinator(nil) + } + } + + detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in + self?.deselectRoomIfNeeded(roomIdentifier: roomIdentifier) + } + + if navigationSplitCoordinator.detailCoordinator == nil { + navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated) + } + } + } // MARK: Settings diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift index 83885b5d7..ad502e803 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift @@ -77,6 +77,9 @@ class UserSessionFlowCoordinatorStateMachine { case showInvitesScreen /// The invites screen has been dismissed case closedInvitesScreen + + /// Request presentation of the settings of a specific room + case selectRoomDetails(roomId: String) } private let stateMachine: StateMachine @@ -129,6 +132,9 @@ class UserSessionFlowCoordinatorStateMachine { return .invitesScreen(selectedRoomId: roomId) case (.deselectRoom, .invitesScreen): return .invitesScreen(selectedRoomId: nil) + + case (.selectRoomDetails(let roomId), .roomList): + return .roomList(selectedRoomId: roomId) default: return nil diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 34e7d2805..060a614eb 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -368,9 +368,9 @@ class MockScreen: Identifiable { ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.dropFirst(1).compactMap(\.id)) let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie + clientProxy.roomForIdentifierMocks["someAwesomeRoomId1"] = .init(with: .init(displayName: "First room", inviter: .mockCharlie)) + clientProxy.roomForIdentifierMocks["someAwesomeRoomId2"] = .init(with: .init(displayName: "Second room", inviter: .mockCharlie)) let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) - clientProxy.visibleRoomsSummaryProvider = summaryProvider clientProxy.invitesSummaryProvider = summaryProvider let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) @@ -380,9 +380,9 @@ class MockScreen: Identifiable { ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.compactMap(\.id)) let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie + clientProxy.roomForIdentifierMocks["someAwesomeRoomId1"] = .init(with: .init(displayName: "First room", inviter: .mockCharlie)) + clientProxy.roomForIdentifierMocks["someAwesomeRoomId2"] = .init(with: .init(displayName: "Second room", inviter: .mockCharlie)) let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) - clientProxy.visibleRoomsSummaryProvider = summaryProvider clientProxy.invitesSummaryProvider = summaryProvider let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index f5bf00c7a..6a4cc982a 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -21,13 +21,17 @@ import XCTest @MainActor class HomeScreenViewModelTests: XCTestCase { var viewModel: HomeScreenViewModelProtocol! - var context: HomeScreenViewModelType.Context! + var clientProxy: MockClientProxy! + + var context: HomeScreenViewModelType.Context! { + viewModel.context + } override func setUpWithError() throws { - viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), + clientProxy = MockClientProxy(userID: "@mock:client.com") + viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), attributedStringBuilder: AttributedStringBuilder()) - context = viewModel.context } func testSelectRoom() async throws { @@ -65,4 +69,59 @@ class HomeScreenViewModelTests: XCTestCase { await Task.yield() XCTAssert(correctResult) } + + func testLeaveRoomAlert() async throws { + let mockRoomId = "1" + clientProxy.roomForIdentifierMocks[mockRoomId] = .init(with: .init(id: mockRoomId, displayName: "Some room")) + context.send(viewAction: .leaveRoom(roomIdentifier: mockRoomId)) + await context.nextViewState() + XCTAssertEqual(context.leaveRoomAlertItem?.roomId, mockRoomId) + } + + func testLeaveRoomError() async throws { + let mockRoomId = "1" + let room: RoomProxyMock = .init(with: .init(id: mockRoomId, displayName: "Some room")) + room.leaveRoomClosure = { .failure(.failedLeavingRoom) } + clientProxy.roomForIdentifierMocks[mockRoomId] = room + context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) + let newState = await context.nextViewState() + XCTAssertNotNil(context.alertInfo) + } + + func testLeaveRoomSuccess() async throws { + let mockRoomId = "1" + var correctResult = false + viewModel.callback = { result in + switch result { + case .roomLeft(let roomIdentifier): + correctResult = roomIdentifier == mockRoomId + default: + break + } + } + let room: RoomProxyMock = .init(with: .init(id: mockRoomId, displayName: "Some room")) + room.leaveRoomClosure = { .success(()) } + clientProxy.roomForIdentifierMocks[mockRoomId] = room + context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) + await Task.yield() + XCTAssertNil(context.alertInfo) + XCTAssertTrue(correctResult) + } + + func testShowRoomDetails() async throws { + let mockRoomId = "1" + var correctResult = false + viewModel.callback = { result in + switch result { + case .presentRoomDetails(let roomIdentifier): + correctResult = roomIdentifier == mockRoomId + default: + break + } + } + context.send(viewAction: .showRoomDetails(roomIdentifier: mockRoomId)) + await Task.yield() + XCTAssertNil(context.alertInfo) + XCTAssertTrue(correctResult) + } } diff --git a/UnitTests/Sources/InvitesViewModelTests.swift b/UnitTests/Sources/InvitesScreenViewModelTests.swift similarity index 98% rename from UnitTests/Sources/InvitesViewModelTests.swift rename to UnitTests/Sources/InvitesScreenViewModelTests.swift index 3ecdc648b..daf00afd1 100644 --- a/UnitTests/Sources/InvitesViewModelTests.swift +++ b/UnitTests/Sources/InvitesScreenViewModelTests.swift @@ -18,7 +18,7 @@ import XCTest @MainActor -class InvitesViewModelTests: XCTestCase { +class InvitesScreenViewModelTests: XCTestCase { var viewModel: InvitesScreenViewModelProtocol! var clientProxy: MockClientProxy! var userSession: MockUserSession!