diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 6ff7f786c..72efc0217 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -741,6 +741,40 @@ class RoomProxyMock: RoomProxyProtocol { return inviterReturnValue } } + //MARK: - rejectInvitation + + var rejectInvitationCallsCount = 0 + var rejectInvitationCalled: Bool { + return rejectInvitationCallsCount > 0 + } + var rejectInvitationReturnValue: Result! + var rejectInvitationClosure: (() async -> Result)? + + func rejectInvitation() async -> Result { + rejectInvitationCallsCount += 1 + if let rejectInvitationClosure = rejectInvitationClosure { + return await rejectInvitationClosure() + } else { + return rejectInvitationReturnValue + } + } + //MARK: - acceptInvitation + + var acceptInvitationCallsCount = 0 + var acceptInvitationCalled: Bool { + return acceptInvitationCallsCount > 0 + } + var acceptInvitationReturnValue: Result! + var acceptInvitationClosure: (() async -> Result)? + + func acceptInvitation() async -> Result { + acceptInvitationCallsCount += 1 + if let acceptInvitationClosure = acceptInvitationClosure { + return await acceptInvitationClosure() + } else { + return acceptInvitationReturnValue + } + } } class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index dd749bae5..32e25fd44 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -118,5 +118,7 @@ struct A11yIdentifiers { struct InvitesScreen { let noInvites = "invites-no_invites" + let accept = "invites-accept" + let decline = "invites-decline" } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 9534ffe56..c795cf9da 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -31,7 +31,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol var callback: ((HomeScreenViewModelAction) -> Void)? - // swiftlint:disable:next function_body_length + // swiftlint:disable:next function_body_length cyclomatic_complexity init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) { self.userSession = userSession self.attributedStringBuilder = attributedStringBuilder diff --git a/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift b/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift index 115a2824d..06152e44d 100644 --- a/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift +++ b/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift @@ -21,7 +21,9 @@ struct InvitesCoordinatorParameters { let userSession: UserSessionProtocol } -enum InvitesCoordinatorAction { } +enum InvitesCoordinatorAction { + case openRoom(withIdentifier: String) +} final class InvitesCoordinator: CoordinatorProtocol { private let parameters: InvitesCoordinatorParameters @@ -39,10 +41,15 @@ final class InvitesCoordinator: CoordinatorProtocol { } func start() { - viewModel.actions.sink { [weak self] _ in - guard let self else { return } - } - .store(in: &cancellables) + viewModel.actions + .sink { [weak self] action in + guard let self else { return } + switch action { + case .openRoom(let roomID): + self.actionsSubject.send(.openRoom(withIdentifier: roomID)) + } + } + .store(in: &cancellables) } func toPresentable() -> AnyView { diff --git a/ElementX/Sources/Screens/Invites/InvitesModels.swift b/ElementX/Sources/Screens/Invites/InvitesModels.swift index 9ead3f8c1..f4432080c 100644 --- a/ElementX/Sources/Screens/Invites/InvitesModels.swift +++ b/ElementX/Sources/Screens/Invites/InvitesModels.swift @@ -14,10 +14,17 @@ // limitations under the License. // -enum InvitesViewModelAction { } +enum InvitesViewModelAction { + case openRoom(withIdentifier: String) +} struct InvitesViewState: BindableState { var invites: [InvitesRoomDetails]? + var bindings: InvitesViewStateBindings = .init() +} + +struct InvitesViewStateBindings { + var alertInfo: AlertInfo? } struct InvitesRoomDetails { diff --git a/ElementX/Sources/Screens/Invites/InvitesViewModel.swift b/ElementX/Sources/Screens/Invites/InvitesViewModel.swift index f4cec7d01..334e79bfc 100644 --- a/ElementX/Sources/Screens/Invites/InvitesViewModel.swift +++ b/ElementX/Sources/Screens/Invites/InvitesViewModel.swift @@ -37,17 +37,21 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol { override func process(viewAction: InvitesViewAction) { switch viewAction { - case .accept: - break - case .decline: - break + case .accept(let invite): + accept(invite: invite) + case .decline(let invite): + startDeclineFlow(invite: invite) } } // MARK: - Private + private var clientProxy: ClientProxyProtocol { + userSession.clientProxy + } + private var invitesSummaryProvider: RoomSummaryProviderProtocol? { - userSession.clientProxy.invitesSummaryProvider + clientProxy.invitesSummaryProvider } private func setupSubscriptions() { @@ -57,6 +61,7 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol { } invitesSummaryProvider.roomListPublisher + .receive(on: DispatchQueue.main) .sink { [weak self] roomSummaries in guard let self else { return } @@ -72,7 +77,7 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol { private func fetchInviter(for roomID: String) { Task { - guard let room: RoomProxyProtocol = await self.userSession.clientProxy.roomForIdentifier(roomID) else { + guard let room: RoomProxyProtocol = await self.clientProxy.roomForIdentifier(roomID) else { return } @@ -85,6 +90,69 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol { state.invites?[inviteIndex].inviter = inviter } } + + private func startDeclineFlow(invite: InvitesRoomDetails) { + let roomPlaceholder = invite.isDirect ? (invite.inviter?.displayName ?? invite.roomDetails.name) : invite.roomDetails.name + let title = invite.isDirect ? L10n.screenInvitesDeclineDirectChatTitle : L10n.screenInvitesDeclineChatTitle + let message = invite.isDirect ? L10n.screenInvitesDeclineDirectChatMessage(roomPlaceholder) : L10n.screenInvitesDeclineChatMessage(roomPlaceholder) + + state.bindings.alertInfo = .init(id: true, + title: title, + message: message, + primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), + secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { self.decline(invite: invite) })) + } + + private func accept(invite: InvitesRoomDetails) { + Task { + let roomID = invite.roomDetails.id + defer { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(roomID) + } + + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true)) + + guard let roomProxy = await clientProxy.roomForIdentifier(roomID) else { + displayError(.failedAcceptingInvite) + return + } + + switch await roomProxy.acceptInvitation() { + case .success: + actionsSubject.send(.openRoom(withIdentifier: roomID)) + case .failure(let error): + displayError(error) + } + } + } + + private func decline(invite: InvitesRoomDetails) { + Task { + let roomID = invite.roomDetails.id + defer { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(roomID) + } + + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true)) + + guard let roomProxy = await clientProxy.roomForIdentifier(roomID) else { + displayError(.failedRejectingInvite) + return + } + + let result = await roomProxy.rejectInvitation() + + if case .failure(let error) = result { + displayError(error) + } + } + } + + private func displayError(_ error: RoomProxyError) { + state.bindings.alertInfo = .init(id: true, + title: L10n.commonError, + message: L10n.errorUnknown) + } } private extension Array where Element == RoomSummary { diff --git a/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift b/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift index ee09c2198..0291fbc71 100644 --- a/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift +++ b/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift @@ -35,6 +35,7 @@ struct InvitesScreen: View { } } .navigationTitle(L10n.actionInvitesList) + .alert(item: $context.alertInfo) { $0.alert } } // MARK: - Private diff --git a/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift b/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift index 120badda2..78ea1190a 100644 --- a/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift +++ b/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift @@ -86,9 +86,11 @@ struct InvitesScreenCell: View { HStack(spacing: 12) { Button(L10n.actionDecline, action: declineAction) .buttonStyle(.elementCapsule) + .accessibilityIdentifier(A11yIdentifiers.invitesScreen.decline) Button(L10n.actionAccept, action: acceptAction) .buttonStyle(.elementCapsuleProminent) + .accessibilityIdentifier(A11yIdentifiers.invitesScreen.accept) } } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 887fac539..ba150a1e0 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -65,6 +65,7 @@ class MockClientProxy: ClientProxyProtocol { 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 } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 329e43418..e84119549 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -348,6 +348,26 @@ class RoomProxy: RoomProxyProtocol { } } + func rejectInvitation() async -> Result { + await Task.dispatch(on: .global()) { + do { + return try .success(self.room.rejectInvitation()) + } catch { + return .failure(.failedRejectingInvite) + } + } + } + + func acceptInvitation() async -> Result { + await Task.dispatch(on: .global()) { + do { + return try .success(self.room.acceptInvitation()) + } catch { + return .failure(.failedAcceptingInvite) + } + } + } + // MARK: - Private /// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 5338c5392..7e78e076e 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -33,6 +33,8 @@ enum RoomProxyError: Error { case failedAddingTimelineListener case failedRetrievingMembers case failedLeavingRoom + case failedAcceptingInvite + case failedRejectingInvite } @MainActor @@ -90,6 +92,10 @@ protocol RoomProxyProtocol { func updateMembers() async func inviter() async -> RoomMemberProxyProtocol? + + func rejectInvitation() async -> Result + + func acceptInvitation() async -> Result } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift index 62382aba3..06eed03b9 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift @@ -129,7 +129,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol { let details = RoomSummaryDetails(id: room.roomId(), name: room.name() ?? room.roomId(), - isDirect: room.isDm() ?? false, + isDirect: fullRoom?.isDirect() ?? room.isDm() ?? false, avatarURL: avatarURL, lastMessage: attributedLastMessage, lastMessageFormattedTimestamp: lastMessageFormattedTimestamp, diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 95a242a40..8a5db711b 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -121,6 +121,11 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.presentInvitesList(animated: animated) case (.invitesScreen, .closedInvitesScreen, .roomList): break + case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId: + self.presentRoomWithIdentifier(roomId) + case (.invitesScreen, .deselectRoom, .invitesScreen): + break + default: fatalError("Unknown transition: \(context)") } @@ -200,12 +205,18 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in guard let self else { return } - // Move the state machine to no room selected if the room currently being dimissed + // Move the state machine to no room selected if the room currently being dismissed // is the same as the one selected in the state machine. // This generally happens when popping the room screen while in a compact layout - if case let .roomList(selectedRoomId) = self.stateMachine.state, selectedRoomId == roomIdentifier { + switch self.stateMachine.state { + case + let .roomList(selectedRoomId) where selectedRoomId == roomIdentifier, + let .invitesScreen(selectedRoomId) where selectedRoomId == roomIdentifier: + self.stateMachine.processEvent(.deselectRoom) self.detailNavigationStackCoordinator.setRootCoordinator(nil) + default: + break } } @@ -330,10 +341,15 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let coordinator = InvitesCoordinator(parameters: parameters) coordinator.actions - .sink { _ in } + .sink { [weak self] action in + switch action { + case .openRoom(let roomId): + self?.stateMachine.processEvent(.selectRoom(roomId: roomId)) + } + } .store(in: &cancellables) - navigationSplitCoordinator.setDetailCoordinator(coordinator, animated: animated) { [weak self] in + sidebarNavigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in self?.stateMachine.processEvent(.closedInvitesScreen) } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift index 36b564ba9..83885b5d7 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift @@ -23,7 +23,7 @@ class UserSessionFlowCoordinatorStateMachine { /// The initial state, used before the coordinator starts case initial - /// Showing the home screen + /// Showing the home screen. The `selectedRoomId` represents the timeline shown on the detail panel (if any) case roomList(selectedRoomId: String?) /// Showing the session verification flows @@ -125,7 +125,11 @@ class UserSessionFlowCoordinatorStateMachine { return .invitesScreen(selectedRoomId: selectedRoomId) case (.closedInvitesScreen, .invitesScreen(let selectedRoomId)): return .roomList(selectedRoomId: selectedRoomId) - + case (.selectRoom(let roomId), .invitesScreen): + return .invitesScreen(selectedRoomId: roomId) + case (.deselectRoom, .invitesScreen): + return .invitesScreen(selectedRoomId: nil) + default: return nil } diff --git a/UITests/Sources/InvitesScreenUITests.swift b/UITests/Sources/InvitesScreenUITests.swift index 1dde28646..f0aa15418 100644 --- a/UITests/Sources/InvitesScreenUITests.swift +++ b/UITests/Sources/InvitesScreenUITests.swift @@ -28,4 +28,13 @@ class InvitesScreenUITests: XCTestCase { XCTAssertTrue(app.staticTexts[A11yIdentifiers.invitesScreen.noInvites].exists) app.assertScreenshot(.invitesNoInvites) } + + func testDeclineInvite() { + let app = Application.launch(.invites) + let declineButton = app.buttons[A11yIdentifiers.invitesScreen.decline].firstMatch + XCTAssert(declineButton.exists) + declineButton.tap() + XCTAssertEqual(app.alerts.count, 1) + app.assertScreenshot(.invites, step: 1) + } } diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites-1.png new file mode 100644 index 000000000..bc30e79fc --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:227793880d0425d98c0dbb1d8a5f6439432383d9a9506030b9f4838bf9c0ce4d +size 188175 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites-1.png new file mode 100644 index 000000000..f036f9e16 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da9cae78d8cf7592e20aedd014801c912e0feb90a1a9365b6ed99bea5b11bc1f +size 356615 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites-1.png new file mode 100644 index 000000000..40dc88377 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3221977873adc6942d96a283f8625a835277201db4897a72fd6942a8191ed221 +size 233543 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites-1.png new file mode 100644 index 000000000..3fea415bd --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fe7a52291eb74567927f6da56a533f1dfc616a899d1174e65d36198ca65ca11 +size 461856 diff --git a/UnitTests/Sources/InvitesViewModelTests.swift b/UnitTests/Sources/InvitesViewModelTests.swift index 6afc7e01c..831dadc16 100644 --- a/UnitTests/Sources/InvitesViewModelTests.swift +++ b/UnitTests/Sources/InvitesViewModelTests.swift @@ -32,24 +32,56 @@ class InvitesViewModelTests: XCTestCase { userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) } - func testEmptyState() throws { + func testEmptyState() async throws { setupViewModel() + _ = await context.nextViewState() let invites = try XCTUnwrap(context.viewState.invites) XCTAssertTrue(invites.isEmpty) } - func testListState() throws { - let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) - clientProxy.invitesSummaryProvider = summaryProvider - clientProxy.visibleRoomsSummaryProvider = summaryProvider - setupViewModel() + func testListState() async throws { + setupViewModel(roomSummaries: .mockInvites) + _ = await context.nextViewState() let invites = try XCTUnwrap(context.viewState.invites) XCTAssertEqual(invites.count, 2) } + func testAcceptInvite() async throws { + let invites: [RoomSummary] = .mockInvites + guard case .filled(let details) = invites.first else { + XCTFail("No invite found") + return + } + setupViewModel(roomSummaries: invites) + context.send(viewAction: .accept(.init(roomDetails: details))) + let action: InvitesViewModelAction? = await viewModel.actions.values.first() + guard case .openRoom(let roomID) = action else { + XCTFail("Wrong view model action") + return + } + XCTAssertEqual(details.id, roomID) + } + + func testDeclineInvite() async throws { + let invites: [RoomSummary] = .mockInvites + guard case .filled(let details) = invites.first else { + XCTFail("No invite found") + return + } + setupViewModel(roomSummaries: invites) + context.send(viewAction: .decline(.init(roomDetails: details))) + XCTAssertNotNil(context.alertInfo) + } + // MARK: - Private - private func setupViewModel() { + private func setupViewModel(roomSummaries: [RoomSummary]? = nil) { + if let roomSummaries { + let summaryProvider = MockRoomSummaryProvider(state: .loaded(roomSummaries)) + clientProxy.invitesSummaryProvider = summaryProvider + clientProxy.visibleRoomsSummaryProvider = summaryProvider + } + viewModel = InvitesViewModel(userSession: userSession) } } diff --git a/changelog.d/621.feature b/changelog.d/621.feature new file mode 100644 index 000000000..55ad63aca --- /dev/null +++ b/changelog.d/621.feature @@ -0,0 +1 @@ +Users can accept and decline invites. \ No newline at end of file