diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b253d6bf8..998d79eca 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -673,6 +673,7 @@ 7A642EE5F1ADC5D520F21924 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; }; 7A71AEF419904209BB8C2833 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; }; 7A8B264506D3DDABC01B4EEB /* AppMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53AC78E49A297AC1D72A7CF /* AppMediator.swift */; }; + 7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */; }; 7B1605C6FFD4D195F264A684 /* RoomPollsHistoryScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40233F2989AD49906BB310D /* RoomPollsHistoryScreenViewModelTests.swift */; }; 7B3A59786DB2F741A1743ED0 /* PinnedEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510E89B989477E5EE8E503C0 /* PinnedEventsTimelineScreenViewModelProtocol.swift */; }; 7B66DA4E7E5FE4D1A0FCEAA4 /* JoinRoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAB5662310AE73D93815134 /* JoinRoomScreenViewModelProtocol.swift */; }; @@ -2297,6 +2298,7 @@ A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = ""; }; A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = ""; }; A103580EBA06155B1343EF16 /* HomeScreenKnockedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenKnockedCell.swift; sourceTree = ""; }; + A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModelTests.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModelProtocol.swift; sourceTree = ""; }; A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = ""; }; @@ -4505,6 +4507,7 @@ DEBB74427E24AF30CDB131B7 /* DeferredFulfillmentTests.swift */, 6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */, 906451FB8CF27C628152BF7A /* EditRoomAddressScreenViewModelTests.swift */, + A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */, 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */, 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */, 1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */, @@ -7223,6 +7226,7 @@ A583B70939707197B0B21DFC /* DeferredFulfillmentTests.swift in Sources */, 864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */, EDB6915EC953BB2A44AA608E /* EditRoomAddressScreenViewModelTests.swift in Sources */, + 7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */, 25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */, 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */, 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index 1418d15d3..4354bbd05 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -82,7 +82,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { case .displayUser(let userID): actionsSubject.send(.displayUser(userID: userID)) case .presentLocationViewer(let geoURI, let description): - presentMapNavigator(geoURI: geoURI, description: description) + presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController) case .displayMessageForwarding(let forwardingItem): presentMessageForwarding(with: forwardingItem) case .displayRoomScreenWithFocussedPin(let eventID): @@ -94,20 +94,20 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { navigationStackCoordinator.setRootCoordinator(coordinator) } - private func presentMapNavigator(geoURI: GeoURI, description: String?) { + private func presentMapNavigator(geoURI: GeoURI, description: String?, timelineController: TimelineControllerProtocol) { let stackCoordinator = NavigationStackCoordinator() let params = StaticLocationScreenCoordinatorParameters(interactionMode: .viewOnly(geoURI: geoURI, description: description), mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration, - appMediator: flowParameters.appMediator) + timelineController: timelineController, + appMediator: flowParameters.appMediator, + analytics: flowParameters.analytics, + userIndicatorController: flowParameters.userIndicatorController) let coordinator = StaticLocationScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in guard let self else { return } switch action { - case .selectedLocation: - // We don't handle the sending/picker case in this flow - break case .close: self.navigationStackCoordinator.setSheetCoordinator(nil) } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 7089164ca..ac24d289f 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1005,24 +1005,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { selectedEmoji: Set, timelineController: TimelineControllerProtocol, animated: Bool) { - let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: flowParameters.emojiProvider, - itemID: itemID, selectedEmojis: selectedEmoji) + let params = EmojiPickerScreenCoordinatorParameters(itemID: itemID, + selectedEmojis: selectedEmoji, + emojiProvider: flowParameters.emojiProvider, + timelineController: timelineController) let coordinator = EmojiPickerScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in guard let self else { return } switch action { - case let .emojiSelected(emoji: emoji, itemID: itemID): - MXLog.debug("Selected \(emoji) for \(itemID)") - navigationStackCoordinator.setSheetCoordinator(nil) - Task { - guard case let .event(_, eventOrTransactionID) = itemID else { - fatalError() - } - - await self.timelineController?.toggleReaction(emoji, to: eventOrTransactionID) - } case .dismiss: navigationStackCoordinator.setSheetCoordinator(nil) } @@ -1041,27 +1033,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let params = StaticLocationScreenCoordinatorParameters(interactionMode: interactionMode, mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration, - appMediator: flowParameters.appMediator) + timelineController: timelineController, + appMediator: flowParameters.appMediator, + analytics: flowParameters.analytics, + userIndicatorController: flowParameters.userIndicatorController) let coordinator = StaticLocationScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in guard let self else { return } switch action { - case .selectedLocation(let geoURI, let isUserLocation): - Task { - _ = await timelineController.sendLocation(body: geoURI.bodyMessage, - geoURI: geoURI, - description: nil, - zoomLevel: 15, - assetType: isUserLocation ? .sender : .pin) - self.navigationStackCoordinator.setSheetCoordinator(nil) - } - - self.flowParameters.analytics.trackComposer(inThread: false, - isEditing: false, - isReply: false, - messageType: isUserLocation ? .LocationUser : .LocationPin, - startsThread: nil) case .close: self.navigationStackCoordinator.setSheetCoordinator(nil) } @@ -1077,36 +1057,19 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func presentPollForm(mode: PollFormMode, timelineController: TimelineControllerProtocol) { let stackCoordinator = NavigationStackCoordinator() - let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode)) + let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode, + timelineController: timelineController, + analytics: flowParameters.analytics, + userIndicatorController: flowParameters.userIndicatorController)) stackCoordinator.setRootCoordinator(coordinator) coordinator.actions .sink { [weak self] action in - guard let self else { - return - } - - self.navigationStackCoordinator.setSheetCoordinator(nil) - + guard let self else { return } + switch action { - case .cancel: - break - case .delete: - deletePoll(mode: mode) - case let .submit(question, options, pollKind): - switch mode { - case .new: - createPoll(question: question, - options: options, - pollKind: pollKind, - timelineController: timelineController) - case .edit(let eventID, _): - editPoll(pollStartID: eventID, - question: question, - options: options, - pollKind: pollKind, - timelineController: timelineController) - } + case .close: + navigationStackCoordinator.setSheetCoordinator(nil) } } .store(in: &cancellables) @@ -1116,58 +1079,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func createPoll(question: String, options: [String], pollKind: Poll.Kind, timelineController: TimelineControllerProtocol) { - Task { - let result = await timelineController.createPoll(question: question, answers: options, pollKind: pollKind) - - self.flowParameters.analytics.trackComposer(inThread: false, - isEditing: false, - isReply: false, - messageType: .Poll, - startsThread: nil) - - self.flowParameters.analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count) - - switch result { - case .success: - break - case .failure: - self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - } - } - } - - private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind, timelineController: TimelineControllerProtocol) { - Task { - let result = await timelineController.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind) - - switch result { - case .success: - break - case .failure: - self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - } - } - } - - private func deletePoll(mode: PollFormMode) { - Task { - guard case .edit(let pollStartID, _) = mode else { - self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - return - } - - let result = await roomProxy.redact(pollStartID) - - switch result { - case .success: - break - case .failure: - self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - } - } - } - private func presentRoomPollsHistory(animated: Bool) async { let userID = userSession.clientProxy.userID diff --git a/ElementX/Sources/Mocks/TimelineProxyMock.swift b/ElementX/Sources/Mocks/TimelineProxyMock.swift index dae96feb2..78049aabd 100644 --- a/ElementX/Sources/Mocks/TimelineProxyMock.swift +++ b/ElementX/Sources/Mocks/TimelineProxyMock.swift @@ -22,6 +22,8 @@ extension TimelineProxyMock { paginateBackwardsRequestSizeReturnValue = .success(()) paginateForwardsRequestSizeReturnValue = .success(()) sendReadReceiptForTypeReturnValue = .success(()) + createPollQuestionAnswersPollKindReturnValue = .success(()) + editPollOriginalQuestionAnswersPollKindReturnValue = .success(()) if configuration.isAutoUpdating { underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock() diff --git a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenCoordinator.swift b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenCoordinator.swift index 927efb9cb..ba4553b90 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenCoordinator.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenCoordinator.swift @@ -12,12 +12,13 @@ struct PollFormScreenCoordinatorParameters { let mode: PollFormMode /// The max number of allowed options, if no value provided the default value of the view model will be used. var maxNumberOfOptions: Int? + let timelineController: TimelineControllerProtocol + let analytics: AnalyticsService + let userIndicatorController: UserIndicatorControllerProtocol } enum PollFormScreenCoordinatorAction { - case cancel - case delete - case submit(question: String, options: [String], pollKind: Poll.Kind) + case close } final class PollFormScreenCoordinator: CoordinatorProtocol { @@ -30,7 +31,11 @@ final class PollFormScreenCoordinator: CoordinatorProtocol { } init(parameters: PollFormScreenCoordinatorParameters) { - viewModel = PollFormScreenViewModel(mode: parameters.mode, maxNumberOfOptions: parameters.maxNumberOfOptions) + viewModel = PollFormScreenViewModel(mode: parameters.mode, + maxNumberOfOptions: parameters.maxNumberOfOptions, + timelineController: parameters.timelineController, + analytics: parameters.analytics, + userIndicatorController: parameters.userIndicatorController) } func start() { @@ -39,12 +44,8 @@ final class PollFormScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .cancel: - self.actionsSubject.send(.cancel) - case .delete: - self.actionsSubject.send(.delete) - case let .submit(question, options, pollKind): - self.actionsSubject.send(.submit(question: question, options: options, pollKind: pollKind)) + case .close: + self.actionsSubject.send(.close) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenModels.swift b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenModels.swift index e4343cb30..82edc0db6 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenModels.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenModels.swift @@ -8,9 +8,7 @@ import Foundation enum PollFormScreenViewModelAction: Equatable { - case cancel - case delete - case submit(question: String, options: [String], pollKind: Poll.Kind) + case close } struct PollFormScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift index 1284804f7..7984f04a1 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift @@ -11,13 +11,24 @@ import SwiftUI typealias PollFormScreenViewModelType = StateStoreViewModelV2 class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewModelProtocol { - private var actionsSubject: PassthroughSubject = .init() + private let timelineController: TimelineControllerProtocol + private let analytics: AnalyticsService + private let userIndicatorController: UserIndicatorControllerProtocol + private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(mode: PollFormMode, maxNumberOfOptions: Int? = nil) { + init(mode: PollFormMode, + maxNumberOfOptions: Int? = nil, + timelineController: TimelineControllerProtocol, + analytics: AnalyticsService, + userIndicatorController: UserIndicatorControllerProtocol) { + self.timelineController = timelineController + self.analytics = analytics + self.userIndicatorController = userIndicatorController + super.init(initialViewState: .init(mode: mode, maxNumberOfOptions: maxNumberOfOptions ?? 20)) } @@ -26,24 +37,33 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo override func process(viewAction: PollFormScreenViewAction) { switch viewAction { case .submit: - actionsSubject.send(.submit(question: state.bindings.question, - options: state.bindings.options.map(\.text), - pollKind: state.bindings.isUndisclosed ? .undisclosed : .disclosed)) + let question = state.bindings.question + let options = state.bindings.options.map(\.text) + let pollKind = state.bindings.isUndisclosed ? Poll.Kind.undisclosed : .disclosed + + Task { + switch state.mode { + case .new: + await createPoll(question: question, options: options, pollKind: pollKind) + case .edit(let eventID, _): + await editPoll(pollStartID: eventID, question: question, options: options, pollKind: pollKind) + } + } case .delete: state.bindings.alertInfo = .init(id: .init(), title: L10n.screenEditPollDeleteConfirmationTitle, message: L10n.screenEditPollDeleteConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.delete) }) + secondaryButton: .init(title: L10n.actionOk) { Task { await self.deletePoll() } }) case .cancel: if state.formContentHasChanged { state.bindings.alertInfo = .init(id: .init(), title: L10n.screenCreatePollCancelConfirmationTitleIos, message: L10n.screenCreatePollCancelConfirmationContentIos, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.cancel) }) + secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.close) }) } else { - actionsSubject.send(.cancel) + actionsSubject.send(.close) } case .deleteOption(let index): // fixes a crash that caused an index out of range when an option with the keyboard focus was deleted @@ -61,4 +81,45 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo state.bindings.options.append(.init()) } } + + // MARK: - Private + + private func createPoll(question: String, options: [String], pollKind: Poll.Kind) async { + guard case .success = await timelineController.createPoll(question: question, answers: options, pollKind: pollKind) else { + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + actionsSubject.send(.close) + + analytics.trackComposer(inThread: false, + isEditing: false, + isReply: false, + messageType: .Poll, + startsThread: nil) + + analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count) + } + + private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind) async { + switch await timelineController.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind) { + case .success: + actionsSubject.send(.close) + case .failure: + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + } + } + + private func deletePoll() async { + // There aren't any local echoes for redactions, so dismiss the screen early + // until we have them: https://github.com/matrix-org/matrix-rust-sdk/issues/4162 + actionsSubject.send(.close) + + guard case .edit(let pollStartID, _) = state.mode else { + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + await timelineController.redact(.eventID(pollStartID)) + } } diff --git a/ElementX/Sources/Screens/CreatePollScreen/View/PollFormScreen.swift b/ElementX/Sources/Screens/CreatePollScreen/View/PollFormScreen.swift index 98a22672c..7d211e7a6 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/View/PollFormScreen.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/View/PollFormScreen.swift @@ -197,8 +197,8 @@ private struct PollFormOptionRow: View { // MARK: - Previews struct PollFormScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = PollFormScreenViewModel(mode: .new) - static let editViewModel = PollFormScreenViewModel(mode: .edit(eventID: "1234", poll: poll)) + static let viewModel = makeViewModel(mode: .new) + static let editViewModel = makeViewModel(mode: .edit(eventID: "1234", poll: poll)) static let poll = Poll(question: "Cats or Dogs?", kind: .disclosed, maxSelections: 1, @@ -222,6 +222,13 @@ struct PollFormScreen_Previews: PreviewProvider, TestablePreview { } .previewDisplayName("Edit") } + + static func makeViewModel(mode: PollFormMode) -> PollFormScreenViewModel { + PollFormScreenViewModel(mode: mode, + timelineController: MockTimelineController(), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) + } } private extension Binding where Value == String { diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift index 4081fcc83..9ffcf7e2a 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift @@ -9,13 +9,13 @@ import Combine import SwiftUI struct EmojiPickerScreenCoordinatorParameters { - let emojiProvider: EmojiProviderProtocol let itemID: TimelineItemIdentifier let selectedEmojis: Set + let emojiProvider: EmojiProviderProtocol + let timelineController: TimelineControllerProtocol } enum EmojiPickerScreenCoordinatorAction { - case emojiSelected(emoji: String, itemID: TimelineItemIdentifier) case dismiss } @@ -33,7 +33,10 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol { init(parameters: EmojiPickerScreenCoordinatorParameters) { self.parameters = parameters - viewModel = EmojiPickerScreenViewModel(emojiProvider: parameters.emojiProvider) + viewModel = EmojiPickerScreenViewModel(itemID: parameters.itemID, + selectedEmojis: parameters.selectedEmojis, + emojiProvider: parameters.emojiProvider, + timelineController: parameters.timelineController) } func start() { @@ -42,8 +45,6 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case let .emojiSelected(emoji: emoji): - actionsSubject.send(.emojiSelected(emoji: emoji, itemID: self.parameters.itemID)) case .dismiss: actionsSubject.send(.dismiss) } @@ -52,6 +53,6 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol { } func toPresentable() -> AnyView { - AnyView(EmojiPickerScreen(context: viewModel.context, selectedEmojis: parameters.selectedEmojis)) + AnyView(EmojiPickerScreen(context: viewModel.context)) } } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift index 5eeba54e6..eea0b4859 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift @@ -8,12 +8,12 @@ import Foundation enum EmojiPickerScreenViewModelAction { - case emojiSelected(emoji: String) case dismiss } struct EmojiPickerScreenViewState: BindableState { var categories: [EmojiPickerEmojiCategoryViewData] + var selectedEmojis: Set } enum EmojiPickerScreenViewAction { diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift index 8a930ba2f..481c5e4bf 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift @@ -11,17 +11,20 @@ import SwiftUI typealias EmojiPickerScreenViewModelType = StateStoreViewModelV2 class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScreenViewModelProtocol { - private var actionsSubject: PassthroughSubject = .init() + private let itemID: TimelineItemIdentifier + private let emojiProvider: EmojiProviderProtocol + private let timelineController: TimelineControllerProtocol + private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - private let emojiProvider: EmojiProviderProtocol - - init(emojiProvider: EmojiProviderProtocol) { - let initialViewState = EmojiPickerScreenViewState(categories: []) + init(itemID: TimelineItemIdentifier, selectedEmojis: Set, emojiProvider: EmojiProviderProtocol, timelineController: TimelineControllerProtocol) { + let initialViewState = EmojiPickerScreenViewState(categories: [], selectedEmojis: selectedEmojis) + self.itemID = itemID self.emojiProvider = emojiProvider + self.timelineController = timelineController super.init(initialViewState: initialViewState) loadEmojis() } @@ -36,8 +39,7 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr state.categories = convert(emojiCategories: categories) } case let .emojiTapped(emoji: emoji): - emojiProvider.markEmojiAsFrequentlyUsed(emoji.value) - actionsSubject.send(.emojiSelected(emoji: emoji.value)) + Task { await selectEmoji(emoji) } case .dismiss: actionsSubject.send(.dismiss) } @@ -62,4 +64,17 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr return EmojiPickerEmojiCategoryViewData(id: emojiCategory.id, emojis: emojisViewData) } } + + private func selectEmoji(_ emoji: EmojiPickerEmojiViewData) async { + MXLog.debug("Selected \(emoji) for \(itemID)") + emojiProvider.markEmojiAsFrequentlyUsed(emoji.value) + + guard case let .event(_, eventOrTransactionID) = itemID else { fatalError("Attempted to react to a virtual item.") } + + // There aren't any local echoes when the toggle redacts, so dismiss the screen early + // until we have them: https://github.com/matrix-org/matrix-rust-sdk/issues/4162 + actionsSubject.send(.dismiss) + + await timelineController.toggleReaction(emoji.value, to: eventOrTransactionID) + } } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift index 9492c3c30..7c6d58c77 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift @@ -11,7 +11,6 @@ import SwiftUI struct EmojiPickerScreen: View { let context: EmojiPickerScreenViewModel.Context - var selectedEmojis = Set() @State var searchString = "" @State private var isSearching = false @@ -64,7 +63,7 @@ struct EmojiPickerScreen: View { } private func accessibilityLabel(for emoji: String) -> String { - if selectedEmojis.contains(emoji) { + if context.viewState.selectedEmojis.contains(emoji) { return L10n.a11yRemoveReaction(emoji) } else { return L10n.a11yAddReaction(emoji) @@ -72,7 +71,7 @@ struct EmojiPickerScreen: View { } private func emojiBackgroundColor(for emoji: String) -> Color { - if selectedEmojis.contains(emoji) { + if context.viewState.selectedEmojis.contains(emoji) { return .compound.bgActionPrimaryRest } else { return .clear @@ -101,22 +100,28 @@ struct EmojiPickerScreen: View { // MARK: - Previews struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) + static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, + selectedEmojis: ["😀", "😄"], + emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), + timelineController: MockTimelineController()) static var previews: some View { - EmojiPickerScreen(context: viewModel.context, selectedEmojis: ["😀", "😄"]) + EmojiPickerScreen(context: viewModel.context) .previewDisplayName("Screen") .snapshotPreferences(expect: viewModel.context.observe(\.viewState.categories).map { !$0.isEmpty }.eraseToStream()) } } struct EmojiPickerScreenSheet_Previews: PreviewProvider { - static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) + static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, + selectedEmojis: ["😀", "😄"], + emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), + timelineController: MockTimelineController()) static var previews: some View { Text("Timeline view") .sheet(isPresented: .constant(true)) { - EmojiPickerScreen(context: viewModel.context, selectedEmojis: ["😀", "😄"]) + EmojiPickerScreen(context: viewModel.context) } .previewDisplayName("Sheet") } diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 05858c0f5..4f2313dc2 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -16,7 +16,6 @@ enum LocationSharingViewError: Error, Hashable { enum StaticLocationScreenViewModelAction { case close case openSystemSettings - case sendLocation(GeoURI, isUserLocation: Bool) } enum StaticLocationInteractionMode: Hashable { diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift index a82943869..5a2ba87f2 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift @@ -11,12 +11,14 @@ import SwiftUI struct StaticLocationScreenCoordinatorParameters { let interactionMode: StaticLocationInteractionMode let mapURLBuilder: MapTilerURLBuilderProtocol + let timelineController: TimelineControllerProtocol let appMediator: AppMediatorProtocol + let analytics: AnalyticsService + let userIndicatorController: UserIndicatorControllerProtocol } enum StaticLocationScreenCoordinatorAction { case close - case selectedLocation(GeoURI, isUserLocation: Bool) } final class StaticLocationScreenCoordinator: CoordinatorProtocol { @@ -33,7 +35,11 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol { init(parameters: StaticLocationScreenCoordinatorParameters) { self.parameters = parameters - viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode, mapURLBuilder: parameters.mapURLBuilder) + viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode, + mapURLBuilder: parameters.mapURLBuilder, + timelineController: parameters.timelineController, + analytics: parameters.analytics, + userIndicatorController: parameters.userIndicatorController) } // MARK: - Public @@ -46,8 +52,6 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.close) case .openSystemSettings: parameters.appMediator.openAppSettings() - case .sendLocation(let geoURI, let isUserLocation): - actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift index fab413323..3e351c35a 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift @@ -11,13 +11,24 @@ import Foundation typealias StaticLocationScreenViewModelType = StateStoreViewModelV2 class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLocationScreenViewModelProtocol { - private let actionsSubject: PassthroughSubject = .init() + private let timelineController: TimelineControllerProtocol + private let analytics: AnalyticsService + private let userIndicatorController: UserIndicatorControllerProtocol + private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(interactionMode: StaticLocationInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol) { + init(interactionMode: StaticLocationInteractionMode, + mapURLBuilder: MapTilerURLBuilderProtocol, + timelineController: TimelineControllerProtocol, + analytics: AnalyticsService, + userIndicatorController: UserIndicatorControllerProtocol) { + self.timelineController = timelineController + self.analytics = analytics + self.userIndicatorController = userIndicatorController + super.init(initialViewState: .init(interactionMode: interactionMode, mapURLBuilder: mapURLBuilder)) } @@ -28,7 +39,7 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo case .selectLocation: guard let coordinate = state.bindings.mapCenterLocation else { return } let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil - actionsSubject.send(.sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation)) + Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) } case .userDidPan: state.bindings.showsUserLocationMode = .show case .centerToUser: @@ -43,4 +54,34 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo } } } + + // MARK: - Private + + private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async { + guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage, + geoURI: geoURI, + description: nil, + zoomLevel: 15, + assetType: isUserLocation ? .sender : .pin) else { + showErrorIndicator() + return + } + + actionsSubject.send(.close) + + analytics.trackComposer(inThread: false, + isEditing: false, + isReply: false, + messageType: isUserLocation ? .LocationUser : .LocationPin, + startsThread: nil) + } + + private func showErrorIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID, + type: .toast, + title: L10n.errorUnknown, + iconName: "xmark")) + } + + private var statusIndicatorID: String { "\(Self.self)-Status" } } diff --git a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift index 3c94e55e4..d5a450303 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift @@ -148,13 +148,22 @@ struct StaticLocationScreen: View { struct StaticLocationScreenViewer_Previews: PreviewProvider, TestablePreview { static let viewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835, longitude: 12.4963655)), - mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration) + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + timelineController: MockTimelineController(), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) static let pickerViewModel = StaticLocationScreenViewModel(interactionMode: .picker, - mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration) + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + timelineController: MockTimelineController(), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) static let descriptionViewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835, longitude: 12.4963655), description: "Cool position"), - mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration) + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + timelineController: MockTimelineController(), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) static var previews: some View { NavigationStack { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift index 1a63ad6dd..2c6add8a8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift @@ -109,7 +109,11 @@ class MockTimelineController: TimelineControllerProtocol { func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { } - func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async { } + func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async { + if let timelineProxy { + _ = await timelineProxy.toggleReaction(reaction, to: eventID) + } + } func edit(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, message: String, @@ -125,6 +129,9 @@ class MockTimelineController: TimelineControllerProtocol { private(set) var redactCalled = false func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async { + if let timelineProxy { + _ = await timelineProxy.redact(eventOrTransactionID, reason: nil) + } redactCalled = true } @@ -248,19 +255,31 @@ class MockTimelineController: TimelineControllerProtocol { // MARK: - Polls func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result { - .success(()) + if let timelineProxy { + _ = await timelineProxy.createPoll(question: question, answers: answers, pollKind: pollKind) + } + return .success(()) } func editPoll(original eventID: String, question: String, answers: [String], pollKind: Poll.Kind) async -> Result { - .success(()) + if let timelineProxy { + _ = await timelineProxy.editPoll(original: eventID, question: question, answers: answers, pollKind: pollKind) + } + return .success(()) } func sendPollResponse(pollStartID: String, answers: [String]) async -> Result { - .success(()) + if let timelineProxy { + _ = await timelineProxy.sendPollResponse(pollStartID: pollStartID, answers: answers) + } + return .success(()) } func endPoll(pollStartID: String, text: String) async -> Result { - .success(()) + if let timelineProxy { + _ = await timelineProxy.endPoll(pollStartID: pollStartID, text: text) + } + return .success(()) } // MARK: - UI Test signalling diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 01001fd7a..7ec505cae 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -685,7 +685,11 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .createPoll: let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new, maxNumberOfOptions: 10)) + let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new, + maxNumberOfOptions: 10, + timelineController: MockTimelineController(), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .encryptionSettings, .encryptionSettingsOutOfSync: diff --git a/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift b/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift new file mode 100644 index 000000000..f2dd7680e --- /dev/null +++ b/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift @@ -0,0 +1,45 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import XCTest + +@testable import ElementX + +@MainActor +final class EmojiPickerScreenViewModelTests: XCTestCase { + var timelineProxy: TimelineProxyMock! + + var viewModel: EmojiPickerScreenViewModel! + var context: EmojiPickerScreenViewModel.Context { viewModel.context } + + func testToggleReaction() async throws { + setupViewModel() + let reaction = "👋" + + let expectation = XCTestExpectation(description: "Toggle reaction") + let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } + timelineProxy.toggleReactionToClosure = { toggledReaction, _ in + XCTAssertEqual(toggledReaction, reaction) + expectation.fulfill() + return .success(()) + } + context.send(viewAction: .emojiTapped(emoji: .init(id: "wave", value: reaction))) + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() + } + + // MARK: - Helpers + + private func setupViewModel(selectedEmojis: Set = []) { + timelineProxy = TimelineProxyMock(.init()) + + viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, + selectedEmojis: selectedEmojis, + emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), + timelineController: MockTimelineController(timelineProxy: timelineProxy)) + } +} diff --git a/UnitTests/Sources/PollFormScreenViewModelTests.swift b/UnitTests/Sources/PollFormScreenViewModelTests.swift index 6897fd035..e2ac84eb5 100644 --- a/UnitTests/Sources/PollFormScreenViewModelTests.swift +++ b/UnitTests/Sources/PollFormScreenViewModelTests.swift @@ -11,17 +11,14 @@ import XCTest @MainActor class PollFormScreenViewModelTests: XCTestCase { + let timelineProxy = TimelineProxyMock(.init()) + var viewModel: PollFormScreenViewModelProtocol! - - var context: PollFormScreenViewModelType.Context { - viewModel.context - } - - override func setUpWithError() throws { - viewModel = PollFormScreenViewModel(mode: .new) - } + var context: PollFormScreenViewModelType.Context { viewModel.context } func testNewPollInitialState() async throws { + setupViewModel() + XCTAssertEqual(context.options.count, 2) XCTAssertTrue(context.options.allSatisfy(\.text.isEmpty)) XCTAssertTrue(context.question.isEmpty) @@ -33,11 +30,12 @@ class PollFormScreenViewModelTests: XCTestCase { context.send(viewAction: .cancel) let action = try await deferred.fulfill() XCTAssertNil(context.alertInfo) - XCTAssertEqual(action, .cancel) + XCTAssertEqual(action, .close) } func testEditPollInitialState() async throws { setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + XCTAssertEqual(context.options.count, 3) XCTAssertTrue(context.options.allSatisfy { !$0.text.isEmpty }) XCTAssertFalse(context.question.isEmpty) @@ -49,10 +47,12 @@ class PollFormScreenViewModelTests: XCTestCase { context.send(viewAction: .cancel) let action = try await deferred.fulfill() XCTAssertNil(context.alertInfo) - XCTAssertEqual(action, .cancel) + XCTAssertEqual(action, .close) } func testNewPollInvalidEmptyOption() { + setupViewModel() + context.question = "foo" context.options[0].text = "bla" context.options[1].text = "bla" @@ -62,6 +62,7 @@ class PollFormScreenViewModelTests: XCTestCase { func testEditPollInvalidEmptyOption() { setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + context.send(viewAction: .addOption) XCTAssertTrue(context.viewState.isSubmitButtonDisabled) @@ -72,6 +73,7 @@ class PollFormScreenViewModelTests: XCTestCase { func testEditPollSubmitButtonState() { setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + XCTAssertTrue(context.viewState.isSubmitButtonDisabled) context.options[0].text = "foo" XCTAssertFalse(context.viewState.isSubmitButtonDisabled) @@ -82,68 +84,89 @@ class PollFormScreenViewModelTests: XCTestCase { } func testNewPollSubmit() async throws { + setupViewModel() + context.question = "foo" context.options[0].text = "bla1" context.options[1].text = "bla2" XCTAssertFalse(context.viewState.isSubmitButtonDisabled) - let deferred = deferFulfillment(viewModel.actions) { action in - switch action { - case .submit: - return true - default: - return false - } + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + let expectation = XCTestExpectation(description: "Create poll") + timelineProxy.createPollQuestionAnswersPollKindClosure = { question, options, kind in + XCTAssertEqual(question, "foo") + XCTAssertEqual(options.count, 2) + XCTAssertEqual(options[0], "bla1") + XCTAssertEqual(options[1], "bla2") + XCTAssertEqual(kind, .disclosed) + expectation.fulfill() + return .success(()) } - context.send(viewAction: .submit) - let action = try await deferred.fulfill() - - guard case .submit(let question, let options, let kind) = action else { - XCTFail("Unexpected action") - return - } - XCTAssertEqual(question, "foo") - XCTAssertEqual(options.count, 2) - XCTAssertEqual(options[0], "bla1") - XCTAssertEqual(options[1], "bla2") - XCTAssertEqual(kind, .disclosed) + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() } func testEditPollSubmit() async throws { setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + context.question = "What is your favorite country?" context.options.append(.init(text: "France 🇫🇷")) XCTAssertFalse(context.viewState.isSubmitButtonDisabled) - let deferred = deferFulfillment(viewModel.actions) { action in - switch action { - case .submit: - return true - default: - return false - } + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + let expectation = XCTestExpectation(description: "Edit poll") + timelineProxy.editPollOriginalQuestionAnswersPollKindClosure = { eventID, question, options, kind in + XCTAssertEqual(eventID, "foo") + XCTAssertEqual(question, "What is your favorite country?") + XCTAssertEqual(options.count, 4) + XCTAssertEqual(options[0], "Italy 🇮🇹") + XCTAssertEqual(options[1], "China 🇨🇳") + XCTAssertEqual(options[2], "USA 🇺🇸") + XCTAssertEqual(options[3], "France 🇫🇷") + XCTAssertEqual(kind, .disclosed) + expectation.fulfill() + return .success(()) } - context.send(viewAction: .submit) - let action = try await deferred.fulfill() - - guard case .submit(let question, let options, let kind) = action else { - XCTFail("Unexpected action") - return - } - XCTAssertEqual(question, "What is your favorite country?") - XCTAssertEqual(options.count, 4) - XCTAssertEqual(options[0], "Italy 🇮🇹") - XCTAssertEqual(options[1], "China 🇨🇳") - XCTAssertEqual(options[2], "USA 🇺🇸") - XCTAssertEqual(options[3], "France 🇫🇷") - XCTAssertEqual(kind, .disclosed) + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() } - private func setupViewModel(mode: PollFormMode) { - viewModel = PollFormScreenViewModel(mode: mode) + func testDeletePoll() async throws { + setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + + context.question = "What is your favorite country?" + context.options.append(.init(text: "France 🇫🇷")) + XCTAssertFalse(context.viewState.isSubmitButtonDisabled) + + let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The alert should be shown.") { $0 == .close } + context.send(viewAction: .delete) + + try await deferredFailure.fulfill() + XCTAssertNotNil(context.alertInfo, "An alert should be shown before deleting the poll.") + + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + let expectation = XCTestExpectation(description: "Delete poll") + timelineProxy.redactReasonClosure = { eventID, _ in + XCTAssertEqual(eventID, .eventID("foo")) + expectation.fulfill() + return .success(()) + } + context.alertInfo?.secondaryButton?.action?() + + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() + } + + // MARK: - Helpers + + private func setupViewModel(mode: PollFormMode = .new) { + viewModel = PollFormScreenViewModel(mode: mode, + timelineController: MockTimelineController(timelineProxy: timelineProxy), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift index eb2dbae6b..b5551a11d 100644 --- a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift +++ b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift @@ -12,18 +12,20 @@ import XCTest @MainActor class StaticLocationScreenViewModelTests: XCTestCase { + let timelineProxy = TimelineProxyMock(.init()) + var viewModel: StaticLocationScreenViewModelProtocol! + var context: StaticLocationScreenViewModel.Context { viewModel.context } private var cancellables = Set() - var context: StaticLocationScreenViewModel.Context { - viewModel.context - } - override func setUpWithError() throws { cancellables.removeAll() let viewModel = StaticLocationScreenViewModel(interactionMode: .picker, - mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration) + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + timelineController: MockTimelineController(timelineProxy: timelineProxy), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock()) viewModel.state.bindings.isLocationAuthorized = true self.viewModel = viewModel } @@ -73,22 +75,18 @@ class StaticLocationScreenViewModelTests: XCTestCase { context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.geolocationUncertainty = 10 - let deferred = deferFulfillment(viewModel.actions) { action in - switch action { - case .sendLocation: - return true - default: - return false - } + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + let expectation = XCTestExpectation(description: "sendLocation") + timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in + XCTAssertEqual(geoURI.uncertainty, 10) + XCTAssertEqual(assetType, .sender) + expectation.fulfill() + return .success(()) } context.send(viewAction: .selectLocation) - guard case .sendLocation(let geoUri, let isUserLocation) = try await deferred.fulfill() else { - XCTFail("Sent action should be 'sendLocation'") - return - } - XCTAssertEqual(geoUri.uncertainty, 10) - XCTAssertTrue(isUserLocation) + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() } func testSendPickedLocation() async throws { @@ -96,21 +94,17 @@ class StaticLocationScreenViewModelTests: XCTestCase { context.isLocationAuthorized = nil context.geolocationUncertainty = 10 - let deferred = deferFulfillment(viewModel.actions) { action in - switch action { - case .sendLocation: - return true - default: - return false - } + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + let expectation = XCTestExpectation(description: "sendLocation") + timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in + XCTAssertEqual(geoURI.uncertainty, nil) + XCTAssertEqual(assetType, .pin) + expectation.fulfill() + return .success(()) } context.send(viewAction: .selectLocation) - guard case .sendLocation(let geoUri, let isUserLocation) = try await deferred.fulfill() else { - XCTFail("Sent action should be 'sendLocation'") - return - } - XCTAssertEqual(geoUri.uncertainty, nil) - XCTAssertFalse(isUserLocation) + await fulfillment(of: [expectation], timeout: 1) + try await deferred.fulfill() } }