From 88d3faf77d5094b442309e75fe1ca06437e08dca Mon Sep 17 00:00:00 2001 From: Flescio Date: Fri, 21 Apr 2023 10:11:15 +0200 Subject: [PATCH] Move search users into a dedicated service (#789) * add users provider with test * add ui test for search users * add changelog * Update ElementX/Sources/Services/Users/UsersProvider.swift Co-authored-by: Alfonso Grillo * add error handling in usersprovider * remove empty section * add search in invite users * add CancellableTask, add setup App Settings in UnitTest, screenshots * rename of UserDiscoveryService * Update ElementX/Sources/Other/Extensions/Publisher.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * new error management for User Discovery Service * Update ElementX/Sources/Other/CancellableTask.swift Co-authored-by: Alfonso Grillo * Update ElementX/Sources/Services/Users/UserDiscoveryService.swift Co-authored-by: Alfonso Grillo * fix invite users and start chat errors * use only one task to fetch user profile --------- Co-authored-by: Alfonso Grillo Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> --- .../Mocks/Generated/GeneratedMocks.swift | 41 +++++++ ElementX/Sources/Mocks/UserProfile+Mock.swift | 4 + ElementX/Sources/Other/CancellableTask.swift | 35 ++++++ .../Sources/Other/Extensions/Publisher.swift | 15 +++ .../InviteUsers/InviteUsersCoordinator.swift | 3 +- .../InviteUsers/InviteUsersModels.swift | 8 +- .../InviteUsers/InviteUsersViewModel.swift | 56 ++++++++- .../InviteUsers/View/InviteUsersScreen.swift | 15 ++- .../StartChat/StartChatCoordinator.swift | 5 +- .../Screens/StartChat/StartChatModels.swift | 9 +- .../StartChat/StartChatViewModel.swift | 114 +++++------------- .../StartChat/View/StartChatScreen.swift | 15 ++- .../SearchUsersSection.swift | 21 ++-- .../UserProfileCell.swift | 0 .../Sources/Services/Client/ClientProxy.swift | 2 +- .../Services/Client/ClientProxyProtocol.swift | 2 +- .../Services/Client/MockClientProxy.swift | 2 +- .../UserSessionFlowCoordinator.swift | 4 +- .../Services/Users/UserDiscoveryService.swift | 77 ++++++++++++ .../Users/UserDiscoveryServiceProtocol.swift | 28 +++++ .../UITests/UITestsAppCoordinator.swift | 29 +++-- .../UITests/UITestsScreenIdentifier.swift | 1 - UITests/Sources/StartChatScreenUITests.swift | 24 +--- ...n-GB-iPad-9th-generation.inviteUsers-1.png | 4 +- .../en-GB-iPad-9th-generation.inviteUsers.png | 4 +- .../en-GB-iPad-9th-generation.startChat-1.png | 3 + .../en-GB-iPad-9th-generation.startChat-2.png | 3 + .../en-GB-iPad-9th-generation.startChat.png | 4 +- .../en-GB-iPhone-14.inviteUsers-1.png | 4 +- .../en-GB-iPhone-14.inviteUsers.png | 4 +- .../en-GB-iPhone-14.startChat-1.png | 3 + .../en-GB-iPhone-14.startChat-2.png | 3 + .../Application/en-GB-iPhone-14.startChat.png | 4 +- ...eudo-iPad-9th-generation.inviteUsers-1.png | 4 +- ...pseudo-iPad-9th-generation.inviteUsers.png | 4 +- ...pseudo-iPad-9th-generation.startChat-1.png | 3 + ...pseudo-iPad-9th-generation.startChat-2.png | 3 + .../pseudo-iPad-9th-generation.startChat.png | 4 +- .../pseudo-iPhone-14.inviteUsers-1.png | 4 +- .../pseudo-iPhone-14.inviteUsers.png | 4 +- .../pseudo-iPhone-14.startChat-1.png | 3 + .../pseudo-iPhone-14.startChat-2.png | 3 + .../pseudo-iPhone-14.startChat.png | 4 +- UnitTests/Sources/Extensions/XCTest.swift | 27 +++++ .../Sources/InviteUsersViewModelTests.swift | 6 +- .../Sources/StartChatViewModelTests.swift | 70 ++--------- .../UserDiscoveryServiceTest.swift | 99 +++++++++++++++ changelog.d/789.change | 1 + 48 files changed, 549 insertions(+), 236 deletions(-) create mode 100644 ElementX/Sources/Other/CancellableTask.swift rename ElementX/Sources/Screens/{SearchUsersSection => UserDiscoverySection}/SearchUsersSection.swift (77%) rename ElementX/Sources/Screens/{SearchUsersSection => UserDiscoverySection}/UserProfileCell.swift (100%) create mode 100644 ElementX/Sources/Services/Users/UserDiscoveryService.swift create mode 100644 ElementX/Sources/Services/Users/UserDiscoveryServiceProtocol.swift create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-2.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-2.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-2.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-2.png create mode 100644 UnitTests/Sources/Extensions/XCTest.swift create mode 100644 UnitTests/Sources/UserDiscoveryService/UserDiscoveryServiceTest.swift create mode 100644 changelog.d/789.change diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 72efc0217..e0641f637 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -874,4 +874,45 @@ class SessionVerificationControllerProxyMock: SessionVerificationControllerProxy } } } +class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol { + + //MARK: - searchProfiles + + var searchProfilesWithCallsCount = 0 + var searchProfilesWithCalled: Bool { + return searchProfilesWithCallsCount > 0 + } + var searchProfilesWithReceivedSearchQuery: String? + var searchProfilesWithReceivedInvocations: [String] = [] + var searchProfilesWithReturnValue: Result<[UserProfile], UserDiscoveryErrorType>! + var searchProfilesWithClosure: ((String) async -> Result<[UserProfile], UserDiscoveryErrorType>)? + + func searchProfiles(with searchQuery: String) async -> Result<[UserProfile], UserDiscoveryErrorType> { + searchProfilesWithCallsCount += 1 + searchProfilesWithReceivedSearchQuery = searchQuery + searchProfilesWithReceivedInvocations.append(searchQuery) + if let searchProfilesWithClosure = searchProfilesWithClosure { + return await searchProfilesWithClosure(searchQuery) + } else { + return searchProfilesWithReturnValue + } + } + //MARK: - fetchSuggestions + + var fetchSuggestionsCallsCount = 0 + var fetchSuggestionsCalled: Bool { + return fetchSuggestionsCallsCount > 0 + } + var fetchSuggestionsReturnValue: Result<[UserProfile], UserDiscoveryErrorType>! + var fetchSuggestionsClosure: (() async -> Result<[UserProfile], UserDiscoveryErrorType>)? + + func fetchSuggestions() async -> Result<[UserProfile], UserDiscoveryErrorType> { + fetchSuggestionsCallsCount += 1 + if let fetchSuggestionsClosure = fetchSuggestionsClosure { + return await fetchSuggestionsClosure() + } else { + return fetchSuggestionsReturnValue + } + } +} // swiftlint:enable all diff --git a/ElementX/Sources/Mocks/UserProfile+Mock.swift b/ElementX/Sources/Mocks/UserProfile+Mock.swift index 6eeb638bc..fd3a0e081 100644 --- a/ElementX/Sources/Mocks/UserProfile+Mock.swift +++ b/ElementX/Sources/Mocks/UserProfile+Mock.swift @@ -26,6 +26,10 @@ extension UserProfile { .init(userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil) } + static var mockBobby: UserProfile { + .init(userID: "@bobby:matrix.org", displayName: "Bobby", avatarURL: nil) + } + static var mockCharlie: UserProfile { .init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil) } diff --git a/ElementX/Sources/Other/CancellableTask.swift b/ElementX/Sources/Other/CancellableTask.swift new file mode 100644 index 000000000..7a1154d30 --- /dev/null +++ b/ElementX/Sources/Other/CancellableTask.swift @@ -0,0 +1,35 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@propertyWrapper +struct CancellableTask { + private var storedValue: Task? + + init(_ value: Task? = nil) { + storedValue = value + } + + var wrappedValue: Task? { + get { + storedValue + } set { + storedValue?.cancel() + storedValue = newValue + } + } +} diff --git a/ElementX/Sources/Other/Extensions/Publisher.swift b/ElementX/Sources/Other/Extensions/Publisher.swift index 274f5921e..18829cbaf 100644 --- a/ElementX/Sources/Other/Extensions/Publisher.swift +++ b/ElementX/Sources/Other/Extensions/Publisher.swift @@ -15,6 +15,7 @@ // import Combine +import Foundation extension Publisher where Self.Failure == Never { func weakAssign(to keyPath: ReferenceWritableKeyPath, on object: Root) -> AnyCancellable { @@ -23,3 +24,17 @@ extension Publisher where Self.Failure == Never { } } } + +extension Publisher where Output == String, Failure == Never { + /// Debounce text queries and remove duplicates. + /// Clearing the text publishes the update immediately. + func debounceAndRemoveDuplicates() -> AnyPublisher { + map { query in + let milliseconds = query.isEmpty ? 0 : 500 + return Just(query).delay(for: .milliseconds(milliseconds), scheduler: DispatchQueue.main) + } + .switchToLatest() + .removeDuplicates() + .eraseToAnyPublisher() + } +} diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift index a483d68a6..ef779506a 100644 --- a/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift @@ -19,6 +19,7 @@ import SwiftUI struct InviteUsersCoordinatorParameters { let userSession: UserSessionProtocol + let userDiscoveryService: UserDiscoveryServiceProtocol } enum InviteUsersCoordinatorAction { @@ -38,7 +39,7 @@ final class InviteUsersCoordinator: CoordinatorProtocol { init(parameters: InviteUsersCoordinatorParameters) { self.parameters = parameters - viewModel = InviteUsersViewModel(userSession: parameters.userSession) + viewModel = InviteUsersViewModel(userSession: parameters.userSession, userDiscoveryService: parameters.userDiscoveryService) } func start() { diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift index b4b3f5383..fca15b325 100644 --- a/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift @@ -16,6 +16,10 @@ import Foundation +enum InviteUsersErrorType: Error { + case unknown +} + enum InviteUsersViewModelAction { case close } @@ -23,7 +27,7 @@ enum InviteUsersViewModelAction { struct InviteUsersViewState: BindableState { var bindings = InviteUsersViewStateBindings() - var usersSection: SearchUsersSection = .init(type: .empty, users: []) + var usersSection: UserDiscoverySection = .init(type: .suggestions, users: []) var selectedUsers: [UserProfile] = [] var isSearching: Bool { @@ -45,7 +49,7 @@ struct InviteUsersViewStateBindings { var searchQuery = "" /// Information describing the currently displayed alert. - var alertInfo: AlertInfo? + var alertInfo: AlertInfo? } enum InviteUsersViewAction { diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift index a080a3d5b..8137546ab 100644 --- a/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift @@ -21,17 +21,19 @@ typealias InviteUsersViewModelType = StateStoreViewModel = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(userSession: UserSessionProtocol) { + init(userSession: UserSessionProtocol, userDiscoveryService: UserDiscoveryServiceProtocol) { self.userSession = userSession + self.userDiscoveryService = userDiscoveryService super.init(initialViewState: InviteUsersViewState(), imageProvider: userSession.mediaProvider) - fetchSuggestions() + setupSubscriptions() } // MARK: - Public @@ -65,11 +67,53 @@ class InviteUsersViewModel: InviteUsersViewModelType, InviteUsersViewModelProtoc // MARK: - Private - private func fetchSuggestions() { - guard ServiceLocator.shared.settings.startChatUserSuggestionsEnabled else { - state.usersSection = .init(type: .empty, users: []) + private func setupSubscriptions() { + context.$viewState + .map(\.bindings.searchQuery) + .debounceAndRemoveDuplicates() + .sink { [weak self] _ in + self?.fetchUsers() + } + .store(in: &cancellables) + } + + @CancellableTask + private var fetchUsersTask: Task? + + private func fetchUsers() { + guard searchQuery.count >= 3 else { + fetchSuggestions() return } - state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) + fetchUsersTask = Task { + let result = await userDiscoveryService.searchProfiles(with: searchQuery) + guard !Task.isCancelled else { return } + handleResult(for: .searchResult, result: result) + } + } + + private func fetchSuggestions() { + guard ServiceLocator.shared.settings.startChatUserSuggestionsEnabled else { + state.usersSection = .init(type: .suggestions, users: []) + return + } + fetchUsersTask = Task { + let result = await userDiscoveryService.fetchSuggestions() + guard !Task.isCancelled else { return } + handleResult(for: .suggestions, result: result) + } + } + + private func handleResult(for sectionType: UserDiscoverySectionType, result: Result<[UserProfile], UserDiscoveryErrorType>) { + switch result { + case .success(let users): + state.usersSection = .init(type: sectionType, users: users) + case .failure: + break + } + } + + private var searchQuery: String { + context.searchQuery } } diff --git a/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift index 5c1984fc5..d18d2f286 100644 --- a/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift @@ -44,10 +44,10 @@ struct InviteUsersScreen: View { /// The content shown in the form when a search query has been entered. @ViewBuilder private var searchContent: some View { - if context.viewState.hasEmptySearchResults { - noResultsContent - } else { - Form { + Form { + if context.viewState.hasEmptySearchResults { + noResultsContent + } else { usersSection } } @@ -72,7 +72,7 @@ struct InviteUsersScreen: View { .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user)))) } } header: { - if let title = context.viewState.usersSection.type.title { + if let title = context.viewState.usersSection.title { Text(title) } } @@ -120,7 +120,10 @@ struct InviteUsersScreen_Previews: PreviewProvider { static let viewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), mediaProvider: MockMediaProvider()) - return InviteUsersViewModel(userSession: userSession) + let userDiscoveryService = UserDiscoveryServiceMock() + userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice]) + userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice]) + return InviteUsersViewModel(userSession: userSession, userDiscoveryService: userDiscoveryService) }() static var previews: some View { diff --git a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift index ac4cb2e4a..cfcd7fc00 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift @@ -21,6 +21,7 @@ struct StartChatCoordinatorParameters { let userSession: UserSessionProtocol weak var userIndicatorController: UserIndicatorControllerProtocol? let navigationStackCoordinator: NavigationStackCoordinator? + let userDiscoveryService: UserDiscoveryServiceProtocol } enum StartChatCoordinatorAction { @@ -41,7 +42,7 @@ final class StartChatCoordinator: CoordinatorProtocol { init(parameters: StartChatCoordinatorParameters) { self.parameters = parameters - viewModel = StartChatViewModel(userSession: parameters.userSession, userIndicatorController: parameters.userIndicatorController) + viewModel = StartChatViewModel(userSession: parameters.userSession, userIndicatorController: parameters.userIndicatorController, userDiscoveryService: parameters.userDiscoveryService) } func start() { @@ -69,7 +70,7 @@ final class StartChatCoordinator: CoordinatorProtocol { // MARK: - Private private func presentInviteUsersScreen() { - let inviteParameters = InviteUsersCoordinatorParameters(userSession: parameters.userSession) + let inviteParameters = InviteUsersCoordinatorParameters(userSession: parameters.userSession, userDiscoveryService: parameters.userDiscoveryService) let coordinator = InviteUsersCoordinator(parameters: inviteParameters) coordinator.actions.sink { [weak self] result in switch result { diff --git a/ElementX/Sources/Screens/StartChat/StartChatModels.swift b/ElementX/Sources/Screens/StartChat/StartChatModels.swift index a56571898..223735a3c 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatModels.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatModels.swift @@ -16,6 +16,11 @@ import Foundation +enum StartChatErrorType: Error { + case failedCreatingRoom + case unknown +} + enum StartChatViewModelAction { case close case createRoom @@ -24,7 +29,7 @@ enum StartChatViewModelAction { struct StartChatViewState: BindableState { var bindings = StartChatScreenViewStateBindings() - var usersSection: SearchUsersSection = .init(type: .empty, users: []) + var usersSection: UserDiscoverySection = .init(type: .suggestions, users: []) var isSearching: Bool { !bindings.searchQuery.isEmpty @@ -39,7 +44,7 @@ struct StartChatScreenViewStateBindings { var searchQuery = "" /// Information describing the currently displayed alert. - var alertInfo: AlertInfo? + var alertInfo: AlertInfo? } enum StartChatViewAction { diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift index f147dde5d..c5cbeaa80 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift @@ -22,6 +22,7 @@ typealias StartChatViewModelType = StateStoreViewModel = .init() + private let userDiscoveryService: UserDiscoveryServiceProtocol var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -29,13 +30,13 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { weak var userIndicatorController: UserIndicatorControllerProtocol? - init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) { + init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?, userDiscoveryService: UserDiscoveryServiceProtocol) { self.userSession = userSession self.userIndicatorController = userIndicatorController + self.userDiscoveryService = userDiscoveryService super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider) setupBindings() - fetchSuggestions() } // MARK: - Public @@ -70,87 +71,61 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { private func displayError(_ type: ClientProxyError) { switch type { - case .failedRetrievingDirectRoom: - state.bindings.alertInfo = AlertInfo(id: type, - title: L10n.commonError, - message: L10n.screenStartChatErrorStartingChat) - case .failedCreatingRoom: - state.bindings.alertInfo = AlertInfo(id: type, + case .failedCreatingRoom, .failedRetrievingDirectRoom: + state.bindings.alertInfo = AlertInfo(id: .failedCreatingRoom, title: L10n.commonError, message: L10n.screenStartChatErrorStartingChat) + case .failedSearchingUsers: + state.bindings.alertInfo = AlertInfo(id: .unknown) default: - state.bindings.alertInfo = AlertInfo(id: type) + break } } private func setupBindings() { context.$viewState .map(\.bindings.searchQuery) - .map { query in - // debounce search queries but make sure clearing the search updates immediately - let milliseconds = query.isEmpty ? 0 : 500 - return Just(query).delay(for: .milliseconds(milliseconds), scheduler: DispatchQueue.main) - } - .switchToLatest() - .removeDuplicates() + .debounceAndRemoveDuplicates() .sink { [weak self] _ in - self?.fetchData() + self?.fetchUsers() } .store(in: &cancellables) } - private func fetchData() { + @CancellableTask + private var fetchUsersTask: Task? + + private func fetchUsers() { guard searchQuery.count >= 3 else { fetchSuggestions() return } - - Task { - await searchProfiles() + fetchUsersTask = Task { + let result = await userDiscoveryService.searchProfiles(with: searchQuery) + guard !Task.isCancelled else { return } + handleResult(for: .searchResult, result: result) } } - private func searchProfiles() async { - // copies the current query to check later if fetched data must be shown or not - let committedQuery = searchQuery - - async let queriedProfile = getProfileIfPossible() - async let searchedUsers = clientProxy.searchUsers(searchTerm: committedQuery, limit: 5) - - await updateState(committedQuery: committedQuery, - queriedProfile: queriedProfile, - searchResults: try? searchedUsers.get()) - } - - private func updateState(committedQuery: String, queriedProfile: UserProfile?, searchResults: SearchUsersResults?) { - guard committedQuery == searchQuery else { - return - } - - let localProfile = queriedProfile ?? UserProfile(searchQuery: searchQuery) - let allResults = merge(localProfile: localProfile, searchResults: searchResults?.results) - - state.usersSection = .init(type: .searchResult, users: allResults) - } - - private func merge(localProfile: UserProfile?, searchResults: [UserProfile]?) -> [UserProfile] { - guard let localProfile else { - return searchResults ?? [] - } - - let filteredSearchResult = searchResults?.filter { - $0.userID != localProfile.userID - } ?? [] - - return [localProfile] + filteredSearchResult - } - private func fetchSuggestions() { guard ServiceLocator.shared.settings.startChatUserSuggestionsEnabled else { - state.usersSection = .init(type: .empty, users: []) + state.usersSection = .init(type: .suggestions, users: []) return } - state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) + fetchUsersTask = Task { + let result = await userDiscoveryService.fetchSuggestions() + guard !Task.isCancelled else { return } + handleResult(for: .suggestions, result: result) + } + } + + private func handleResult(for sectionType: UserDiscoverySectionType, result: Result<[UserProfile], UserDiscoveryErrorType>) { + switch result { + case .success(let users): + state.usersSection = .init(type: sectionType, users: users) + case .failure: + break + } } private func createDirectRoom(with user: UserProfile) async { @@ -165,14 +140,6 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { } } - private func getProfileIfPossible() async -> UserProfile? { - guard searchQuery.isMatrixIdentifier else { - return nil - } - - return try? await clientProxy.getProfile(for: searchQuery).get() - } - private var clientProxy: ClientProxyProtocol { userSession.clientProxy } @@ -196,18 +163,3 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier) } } - -private extension String { - var isMatrixIdentifier: Bool { - MatrixEntityRegex.isMatrixUserIdentifier(self) - } -} - -private extension UserProfile { - init?(searchQuery: String) { - guard searchQuery.isMatrixIdentifier else { - return nil - } - self.init(userID: searchQuery) - } -} diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift index a03f1565c..790d5b86b 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift @@ -92,7 +92,7 @@ struct StartChatScreen: View { .buttonStyle(FormButtonStyle()) } } header: { - if let title = context.viewState.usersSection.type.title { + if let title = context.viewState.usersSection.title { Text(title) } } @@ -131,12 +131,19 @@ struct StartChatScreen: View { // MARK: - Previews struct StartChat_Previews: PreviewProvider { - static var previews: some View { + static let viewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), mediaProvider: MockMediaProvider()) - let regularViewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) + let userDiscoveryService = UserDiscoveryServiceMock() + userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice]) + userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice]) + let viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil, userDiscoveryService: userDiscoveryService) + return viewModel + }() + + static var previews: some View { NavigationView { - StartChatScreen(context: regularViewModel.context) + StartChatScreen(context: viewModel.context) .tint(.element.accent) } } diff --git a/ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift b/ElementX/Sources/Screens/UserDiscoverySection/SearchUsersSection.swift similarity index 77% rename from ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift rename to ElementX/Sources/Screens/UserDiscoverySection/SearchUsersSection.swift index 5ae4a8b83..6808a3f7f 100644 --- a/ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift +++ b/ElementX/Sources/Screens/UserDiscoverySection/SearchUsersSection.swift @@ -16,22 +16,21 @@ import Foundation -struct SearchUsersSection { - let type: SearchUserSectionType +struct UserDiscoverySection { + let type: UserDiscoverySectionType let users: [UserProfile] -} - -enum SearchUserSectionType: Equatable { - case searchResult - case suggestions - case empty var title: String? { - switch self { - case .searchResult, .empty: + switch type { + case .searchResult: return nil case .suggestions: - return L10n.commonSuggestions + return users.isEmpty ? nil : L10n.commonSuggestions } } } + +enum UserDiscoverySectionType: Equatable { + case searchResult + case suggestions +} diff --git a/ElementX/Sources/Screens/SearchUsersSection/UserProfileCell.swift b/ElementX/Sources/Screens/UserDiscoverySection/UserProfileCell.swift similarity index 100% rename from ElementX/Sources/Screens/SearchUsersSection/UserProfileCell.swift rename to ElementX/Sources/Screens/UserDiscoverySection/UserProfileCell.swift diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index e5ca81c66..10c756b2f 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -262,7 +262,7 @@ class ClientProxy: ClientProxyProtocol { } } - func getProfile(for userID: String) async -> Result { + func profile(for userID: String) async -> Result { await Task.dispatch(on: clientQueue) { do { return try .success(.init(sdkUserProfile: self.client.getProfile(userId: userID))) diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 4cc3dfc1c..9631b9cd8 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -98,5 +98,5 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func searchUsers(searchTerm: String, limit: UInt) async -> Result - func getProfile(for userID: String) async -> Result + func profile(for userID: String) async -> Result } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index ba150a1e0..23ca58dd1 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -125,7 +125,7 @@ class MockClientProxy: ClientProxyProtocol { var getProfileResult: Result = .success(.init(userID: "@a:b.com", displayName: "Some user")) private(set) var getProfileCalled = false - func getProfile(for userID: String) async -> Result { + func profile(for userID: String) async -> Result { getProfileCalled = true return getProfileResult } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 8a5db711b..d032313c9 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -287,8 +287,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let startChatNavigationStackCoordinator = NavigationStackCoordinator() let userIndicatorController = UserIndicatorController(rootCoordinator: startChatNavigationStackCoordinator) - - let parameters = StartChatCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController, navigationStackCoordinator: startChatNavigationStackCoordinator) + let userDiscoveryService = UserDiscoveryService(clientProxy: userSession.clientProxy) + let parameters = StartChatCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController, navigationStackCoordinator: startChatNavigationStackCoordinator, userDiscoveryService: userDiscoveryService) let coordinator = StartChatCoordinator(parameters: parameters) coordinator.actions.sink { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Services/Users/UserDiscoveryService.swift b/ElementX/Sources/Services/Users/UserDiscoveryService.swift new file mode 100644 index 000000000..c0170563e --- /dev/null +++ b/ElementX/Sources/Services/Users/UserDiscoveryService.swift @@ -0,0 +1,77 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class UserDiscoveryService: UserDiscoveryServiceProtocol { + private let clientProxy: ClientProxyProtocol + + init(clientProxy: ClientProxyProtocol) { + self.clientProxy = clientProxy + } + + func fetchSuggestions() async -> Result<[UserProfile], UserDiscoveryErrorType> { + .success([.mockAlice, .mockBob, .mockCharlie]) + } + + func searchProfiles(with searchQuery: String) async -> Result<[UserProfile], UserDiscoveryErrorType> { + do { + async let queriedProfile = try? profileIfPossible(with: searchQuery).get() + async let searchedUsers = clientProxy.searchUsers(searchTerm: searchQuery, limit: 5) + let users = try await merge(searchQuery: searchQuery, queriedProfile: queriedProfile, searchResults: searchedUsers.get()) + return .success(users) + } catch { + return .failure(.failedSearchingUsers) + } + } + + private func merge(searchQuery: String, queriedProfile: UserProfile?, searchResults: SearchUsersResults) -> [UserProfile] { + let localProfile = queriedProfile ?? UserProfile(searchQuery: searchQuery) + let searchResults = searchResults.results + guard let localProfile else { + return searchResults + } + + let filteredSearchResult = searchResults.filter { + $0.userID != localProfile.userID + } + + return [localProfile] + filteredSearchResult + } + + private func profileIfPossible(with searchQuery: String) async -> Result { + guard searchQuery.isMatrixIdentifier else { + return .failure(.failedGettingUserProfile) + } + + return await clientProxy.profile(for: searchQuery) + } +} + +private extension String { + var isMatrixIdentifier: Bool { + MatrixEntityRegex.isMatrixUserIdentifier(self) + } +} + +private extension UserProfile { + init?(searchQuery: String) { + guard searchQuery.isMatrixIdentifier else { + return nil + } + self.init(userID: searchQuery) + } +} diff --git a/ElementX/Sources/Services/Users/UserDiscoveryServiceProtocol.swift b/ElementX/Sources/Services/Users/UserDiscoveryServiceProtocol.swift new file mode 100644 index 000000000..5a5116395 --- /dev/null +++ b/ElementX/Sources/Services/Users/UserDiscoveryServiceProtocol.swift @@ -0,0 +1,28 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum UserDiscoveryErrorType: Error { + case failedSearchingUsers + case failedFetchingSuggestedUsers +} + +// sourcery: AutoMockable +protocol UserDiscoveryServiceProtocol { + func searchProfiles(with searchQuery: String) async -> Result<[UserProfile], UserDiscoveryErrorType> + func fetchSuggestions() async -> Result<[UserProfile], UserDiscoveryErrorType> +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index aebec2764..d44ac1c79 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -315,23 +315,24 @@ class MockScreen: Identifiable { navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .startChat: + ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = true let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) + let userDiscoveryMock = UserDiscoveryServiceMock() + userDiscoveryMock.fetchSuggestionsReturnValue = .success([.mockAlice, .mockBob, .mockCharlie]) + userDiscoveryMock.searchProfilesWithReturnValue = .success([]) + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()) + let parameters: StartChatCoordinatorParameters = .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock) + let coordinator = StartChatCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .startChatWithSearchResults: let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - clientProxy.searchUsersResult = .success(.init(results: [.mockAlice], limited: true)) - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) - navigationStackCoordinator.setRootCoordinator(coordinator) - return navigationStackCoordinator - case .startChatSearchingNonexistentID: - let navigationStackCoordinator = NavigationStackCoordinator() - let clientProxy = MockClientProxy(userID: "@mock:client.com") - clientProxy.searchUsersResult = .success(.init(results: [.mockAlice], limited: true)) - clientProxy.getProfileResult = .failure(.failedGettingUserProfile) - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) + let userDiscoveryMock = UserDiscoveryServiceMock() + userDiscoveryMock.fetchSuggestionsReturnValue = .success([]) + userDiscoveryMock.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby]) + let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let coordinator = StartChatCoordinator(parameters: .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomMemberDetailsAccountOwner: @@ -381,8 +382,12 @@ class MockScreen: Identifiable { navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .inviteUsers: + ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = true let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = InviteUsersCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()))) + let userDiscoveryMock = UserDiscoveryServiceMock() + userDiscoveryMock.fetchSuggestionsReturnValue = .success([.mockAlice, .mockBob, .mockCharlie]) + userDiscoveryMock.searchProfilesWithReturnValue = .success([]) + let coordinator = InviteUsersCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()), userDiscoveryService: userDiscoveryMock)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator } diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 1d8f2b8d3..bee3d93ce 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -51,7 +51,6 @@ enum UITestsScreenIdentifier: String { case reportContent case startChat case startChatWithSearchResults - case startChatSearchingNonexistentID case invites case invitesNoInvites case inviteUsers diff --git a/UITests/Sources/StartChatScreenUITests.swift b/UITests/Sources/StartChatScreenUITests.swift index ef50318dd..d6666a726 100644 --- a/UITests/Sources/StartChatScreenUITests.swift +++ b/UITests/Sources/StartChatScreenUITests.swift @@ -26,7 +26,7 @@ class StartChatScreenUITests: XCTestCase { func testSearchWithNoResults() { let app = Application.launch(.startChat) let searchField = app.searchFields.firstMatch - searchField.clearAndTypeText("Someone") + searchField.clearAndTypeText("None") XCTAssert(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) app.assertScreenshot(.startChat, step: 1) } @@ -34,27 +34,9 @@ class StartChatScreenUITests: XCTestCase { func testSearchWithResults() { let app = Application.launch(.startChatWithSearchResults) let searchField = app.searchFields.firstMatch - searchField.clearAndTypeText("Someone") + searchField.clearAndTypeText("Bob") XCTAssertFalse(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) - XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 1) + XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 2) app.assertScreenshot(.startChat, step: 2) } - - func testSearchExactMatrixID() { - let app = Application.launch(.startChatWithSearchResults) - let searchField = app.searchFields.firstMatch - searchField.clearAndTypeText("@a:b.com") - XCTAssertFalse(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) - XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 2) - app.assertScreenshot(.startChat, step: 3) - } - - func testSearchExactNotExistingMatrixID() { - let app = Application.launch(.startChatSearchingNonexistentID) - let searchField = app.searchFields.firstMatch - searchField.clearAndTypeText("@a:b.com") - XCTAssertFalse(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) - XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 2) - app.assertScreenshot(.startChat, step: 4) - } } diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png index b3a266c78..9e4095326 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6296d533f3cdbe4d5f1045ea1ec6357fd96cb6a1599bd6f73f4b784203d32e46 -size 108578 +oid sha256:27f23d7385277433b757e7513bea0c6349c2df2cc1381fdd7ac562d4ae8a2679 +size 108566 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png index 4b508b7c0..05b4893f9 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9941e41387b13e98a9f4ea200015a4df439395fa8284cb0f7786b39d1fb1109a -size 100706 +oid sha256:f627692fab53a50a5cada0b7267d691d17d4dfe18973953ca3f5362f65902dde +size 98917 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-1.png new file mode 100644 index 000000000..ec2829e0c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2576f456ae6a5e10d7df17031c1487a3607492533d4e9a64620fe186e8eacb8d +size 136466 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-2.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-2.png new file mode 100644 index 000000000..f73d028d8 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4cc5da4209e2b62cfd938b8a9c47c3bf59fe07eb8dcde1ad73b7f7ea9498f56c +size 151050 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat.png index 5da07ba5a..87d65386b 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.startChat.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dca25597da470bcaffd8b27e14a3337e3f140a8a52d01bc615ed36fa244d5d56 -size 109818 +oid sha256:0ab1b76232611d7689f41a08377cab9602f358107922d36618fe547ddb34f523 +size 109825 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png index 3df5d34a8..78ba8c4fe 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a69459351362fdce97b948e796bd1238a29d06f017c0ffd9e04dadc647fa4bb9 -size 133013 +oid sha256:b488f2fb2041325f0776e027ea202655adba51a0e12bdaf5ff07d8f4484f7b31 +size 133019 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png index d8f5a8fa1..0478e8798 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1293e87f1cf4c7cae4e836eaebd058393a958a471fd968b74181ed2098f544c -size 118210 +oid sha256:bae5f65a07ef1ba6bc6b573b82f5cda1c4d565ed28c9663ea9a6fd9ffda1c87c +size 113345 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-1.png new file mode 100644 index 000000000..0fd65a935 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edcf7669efe5e3e0124de8b15bb0e7d12705f534a54ee2bbc7b6d6577fc15be2 +size 173595 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-2.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-2.png new file mode 100644 index 000000000..d4524a91f --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:130c4c3c1e6fc4b872ab1dff2c728bb90ffe695490ddebed3efd261d8900931a +size 193286 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat.png index fe7784252..fe2b449e2 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.startChat.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a9795d7fea514d8e1c5ec71b7365dd6c228853e80c770046685bf1bf7117299 -size 133821 +oid sha256:c695db4e372c07c4f0b054673fab92138dbcbb14acf17dcd94541cd67d793ea1 +size 133816 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png index 9c6bacc0b..54c30668b 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:628dbd7758485be17263284d081ac84f6d540a4ca96548ee7033bb1ccbe85418 -size 111666 +oid sha256:7e482ce2b580347a2f73657caf4aba77f330097b5edb792c22e75b0bfd4445af +size 111217 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png index 4fe379a71..9a5aef376 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c5467d6cfc30431e4dfd24844662727d80f21f296121412017b7d7add74f9c4 -size 104488 +oid sha256:fc592dbfcaee93de685ad027de94e6426e16fa523293912c7f3f52b4afd9d196 +size 102328 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-1.png new file mode 100644 index 000000000..29927ec89 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59084649c92cd9450f10cd3c36e6f349eb1c6ae118bdbe3e00c0538ef6e4007b +size 137037 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-2.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-2.png new file mode 100644 index 000000000..cd7cb95f5 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb5e31e230afc158c1a8c2de86a34f909bb27e4c42eee5b1b840b397ed5db275 +size 152584 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat.png index 2cfbd7bc1..f2bbdee23 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.startChat.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ba82bedd6c66f99bd94f9942900d4ebe0e0029f83011b79cceee53f0aa102cd -size 114137 +oid sha256:5ee809d18954f64b7376887a323297c66e6a639b2fb004385eb8143d692bd604 +size 114129 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png index 0ebcf6e6d..a709ea8c0 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71d82d5242965b7f60bc86cef9566e33e869e14d0bf8ac57b3a0b5162c8e4423 -size 137680 +oid sha256:4a44592fe680d00de1cf31d1dd0a6c115131f02849b6a75360e81b736edb170d +size 137142 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png index c407ec062..348d741e4 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9cf437823d358d36db516b2338c6e65eea2f6ddb8996713b17b4760cfc3c3cfb -size 120195 +oid sha256:69e5995f2ce7e53a5d8912bc95562ca7029c1ff52ce7ec2e439a6b78abe28d3a +size 117528 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-1.png new file mode 100644 index 000000000..9f37b1576 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:968b785eb407bd7ef81eae302508f8e81166e2ab3a3c5226d6886840ee2cefbb +size 145486 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-2.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-2.png new file mode 100644 index 000000000..10bef854a --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6742804364719e4bb519d0f04a07ae6b7f9f06cf3e9ae994aef0e184d40cbcda +size 167718 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat.png index f4c67a5f9..45840c937 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.startChat.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15d76e811666454d244d211e828af874f8ad69dda2e808505f59296ad4f9cf9d -size 140955 +oid sha256:aba080e7925f3d3cc9593509e6ba183b9e31a8a7ec7da4d7d574e35f192678a1 +size 140952 diff --git a/UnitTests/Sources/Extensions/XCTest.swift b/UnitTests/Sources/Extensions/XCTest.swift new file mode 100644 index 000000000..1d03e22ca --- /dev/null +++ b/UnitTests/Sources/Extensions/XCTest.swift @@ -0,0 +1,27 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +extension XCTestCase { + func setupAppSettings() { + AppSettings.configureWithSuiteName("io.element.elementx.unitests") + AppSettings.reset() + ServiceLocator.shared.register(appSettings: AppSettings()) + } +} diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index 1b1bfc6d9..487c93d7e 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -22,6 +22,7 @@ import XCTest class InviteUsersScreenViewModelTests: XCTestCase { var viewModel: InviteUsersViewModelProtocol! var clientProxy: MockClientProxy! + var userDiscoveryService: UserDiscoveryServiceMock! var context: InviteUsersViewModel.Context { viewModel.context @@ -29,8 +30,11 @@ class InviteUsersScreenViewModelTests: XCTestCase { override func setUpWithError() throws { clientProxy = .init(userID: "") + userDiscoveryService = UserDiscoveryServiceMock() + userDiscoveryService.fetchSuggestionsReturnValue = .success([]) + userDiscoveryService.searchProfilesWithReturnValue = .success([]) let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) - let viewModel = InviteUsersViewModel(userSession: userSession) + let viewModel = InviteUsersViewModel(userSession: userSession, userDiscoveryService: userDiscoveryService) viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) self.viewModel = viewModel } diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index 0f8766639..fa16de7e8 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -22,6 +22,7 @@ import XCTest class StartChatScreenViewModelTests: XCTestCase { var viewModel: StartChatViewModelProtocol! var clientProxy: MockClientProxy! + var userDiscoveryService: UserDiscoveryServiceMock! var context: StartChatViewModel.Context { viewModel.context @@ -29,66 +30,29 @@ class StartChatScreenViewModelTests: XCTestCase { override func setUpWithError() throws { clientProxy = .init(userID: "") + userDiscoveryService = UserDiscoveryServiceMock() + userDiscoveryService.fetchSuggestionsReturnValue = .success([]) + userDiscoveryService.searchProfilesWithReturnValue = .success([]) let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) - viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) + viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil, userDiscoveryService: userDiscoveryService) + + setupAppSettings() + ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = true } func testQueryShowingNoResults() async throws { await search(query: "A") - XCTAssertEqual(context.viewState.usersSection.type, .empty) + XCTAssertEqual(context.viewState.usersSection.type, .suggestions) + XCTAssertTrue(userDiscoveryService.fetchSuggestionsCalled) await search(query: "AA") - XCTAssertEqual(context.viewState.usersSection.type, .empty) + XCTAssertEqual(context.viewState.usersSection.type, .suggestions) + XCTAssertFalse(userDiscoveryService.searchProfilesWithCalled) await search(query: "AAA") assertSearchResults(toBe: 0) - } - - func testQueryShowingResults() async throws { - clientProxy.searchUsersResult = .success(.init(results: [UserProfile.mockAlice], limited: true)) - await search(query: "AAA") - assertSearchResults(toBe: 1) - } - - func testGetProfileIsNotCalled() async { - clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) - clientProxy.getProfileResult = .success(.init(userID: "@alice:matrix.org")) - - await search(query: "AAA") - assertSearchResults(toBe: 3) - XCTAssertFalse(clientProxy.getProfileCalled) - } - - func testLocalResultShows() async { - clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) - clientProxy.getProfileResult = .success(.init(userID: "@some:matrix.org")) - - await search(query: "@a:b.com") - - assertSearchResults(toBe: 4) - XCTAssertTrue(clientProxy.getProfileCalled) - } - - func testLocalResultWithDuplicates() async { - clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) - clientProxy.getProfileResult = .success(.init(userID: "@bob:matrix.org")) - - await search(query: "@a:b.com") - - assertSearchResults(toBe: 3) - let firstUserID = viewModel.context.viewState.usersSection.users.first?.userID - XCTAssertEqual(firstUserID, "@bob:matrix.org") - XCTAssertTrue(clientProxy.getProfileCalled) - } - - func testSearchResultsShowWhenGetProfileFails() async { - clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) - clientProxy.getProfileResult = .failure(.failedGettingUserProfile) - - await search(query: "@a:b.com") - - assertSearchResults(toBe: 4) + XCTAssertTrue(userDiscoveryService.searchProfilesWithCalled) } // MARK: - Private @@ -105,12 +69,4 @@ class StartChatScreenViewModelTests: XCTestCase { viewModel.context.searchQuery = query return await context.$viewState.nextValue } - - private var searchResults: [UserProfile] { - [ - .mockAlice, - .mockBob, - .mockCharlie - ] - } } diff --git a/UnitTests/Sources/UserDiscoveryService/UserDiscoveryServiceTest.swift b/UnitTests/Sources/UserDiscoveryService/UserDiscoveryServiceTest.swift new file mode 100644 index 000000000..9a82be927 --- /dev/null +++ b/UnitTests/Sources/UserDiscoveryService/UserDiscoveryServiceTest.swift @@ -0,0 +1,99 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class UserDiscoveryServiceTest: XCTestCase { + var service: UserDiscoveryService! + var clientProxy: MockClientProxy! + + override func setUpWithError() throws { + clientProxy = .init(userID: "") + service = UserDiscoveryService(clientProxy: clientProxy) + } + + func testQueryShowingResults() async throws { + clientProxy.searchUsersResult = .success(.init(results: [UserProfile.mockAlice], limited: true)) + + let results = await (try? search(query: "AAA").get()) ?? [] + assertSearchResults(results, toBe: 1) + } + + func testGetProfileIsNotCalled() async { + clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) + clientProxy.getProfileResult = .success(.init(userID: "@alice:matrix.org")) + + let results = await (try? search(query: "AAA").get()) ?? [] + assertSearchResults(results, toBe: 3) + XCTAssertFalse(clientProxy.getProfileCalled) + } + + func testLocalResultShows() async { + clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) + clientProxy.getProfileResult = .success(.init(userID: "@some:matrix.org")) + + let results = await (try? search(query: "@a:b.com").get()) ?? [] + + assertSearchResults(results, toBe: 4) + XCTAssertTrue(clientProxy.getProfileCalled) + } + + func testLocalResultWithDuplicates() async { + clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) + clientProxy.getProfileResult = .success(.init(userID: "@bob:matrix.org")) + + let results = await (try? search(query: "@a:b.com").get()) ?? [] + + assertSearchResults(results, toBe: 3) + let firstUserID = results.first?.userID + XCTAssertEqual(firstUserID, "@bob:matrix.org") + XCTAssertTrue(clientProxy.getProfileCalled) + } + + func testSearchResultsShowWhenGetProfileFails() async { + clientProxy.searchUsersResult = .success(.init(results: searchResults, limited: true)) + clientProxy.getProfileResult = .failure(.failedGettingUserProfile) + + let results = await (try? search(query: "@a:b.com").get()) ?? [] + + let firstUserID = results.first?.userID + XCTAssertEqual(firstUserID, "@a:b.com") + XCTAssertTrue(clientProxy.getProfileCalled) + } + + // MARK: - Private + + private func assertSearchResults(_ results: [UserProfile], toBe count: Int) { + XCTAssertTrue(count >= 0) + XCTAssertEqual(results.count, count) + XCTAssertEqual(results.isEmpty, count == 0) + } + + private func search(query: String) async -> Result<[UserProfile], UserDiscoveryErrorType> { + await service.searchProfiles(with: query) + } + + private var searchResults: [UserProfile] { + [ + .mockAlice, + .mockBob, + .mockCharlie + ] + } +} diff --git a/changelog.d/789.change b/changelog.d/789.change new file mode 100644 index 000000000..b9eabaf12 --- /dev/null +++ b/changelog.d/789.change @@ -0,0 +1 @@ +Move search users into UserProvider service