From ea979d65152d0fa7b76e7957bf4ae92954d4d6e7 Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Tue, 4 Apr 2023 10:47:41 +0200 Subject: [PATCH] Improve user search using the GET profile (#754) * Add getProfile in ClientProxyProtocol * Add fetchProfileIfNeeded * Refactor search * Update StartChatSuggestedUserCell UI * Add showProfilePopover action * Remove popover code * Add UTs in StartChatViewModelTests * Add UI tests * Cleanup code * Refine design * Fix design * Localise StartChatSuggestedUserCell * Update localisations * Update StartChatSuggestedUserCell design * Fix UT * Fix PR comments * Apply suggestions * Fix typo --- .../de.lproj/Localizable.strings | 4 +- .../en.lproj/Localizable.strings | 4 +- .../fr.lproj/Localizable.strings | 4 +- .../it.lproj/Localizable.strings | 4 +- .../ro.lproj/Localizable.strings | 2 + ElementX/Sources/Generated/Strings.swift | 4 + .../StartChat/StartChatViewModel.swift | 105 +++++++++++++----- .../StartChatViewModelProtocol.swift | 4 - .../View/StartChatSuggestedUserCell.swift | 14 +++ .../Sources/Services/Client/ClientProxy.swift | 10 ++ .../Services/Client/ClientProxyProtocol.swift | 3 + .../Services/Client/MockClientProxy.swift | 7 ++ .../Sources/Services/Users/UserProfile.swift | 6 + .../UITests/UITestsAppCoordinator.swift | 8 ++ .../UITests/UITestsScreenIdentifier.swift | 1 + UITests/Sources/StartChatScreenUITests.swift | 18 +++ .../Sources/StartChatViewModelTests.swift | 82 ++++++++++++-- 17 files changed, 232 insertions(+), 48 deletions(-) diff --git a/ElementX/Resources/Localizations/de.lproj/Localizable.strings b/ElementX/Resources/Localizations/de.lproj/Localizable.strings index 315919fa5..df111b224 100644 --- a/ElementX/Resources/Localizations/de.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/de.lproj/Localizable.strings @@ -37,7 +37,8 @@ "action_save" = "Save"; "action_search" = "Search"; "action_send" = "Send"; -"action_share_link" = "Share"; +"action_share" = "Share"; +"action_share_link" = "Share link"; "action_skip" = "Skip"; "action_start" = "Start"; "action_start_chat" = "Start chat"; @@ -214,6 +215,7 @@ "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_signout_preference_item" = "Sign out"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; +"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages."; "session_verification_banner_title" = "Access your message history"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 2506ce084..6c634e18e 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -37,7 +37,8 @@ "action_save" = "Save"; "action_search" = "Search"; "action_send" = "Send"; -"action_share_link" = "Share"; +"action_share" = "Share"; +"action_share_link" = "Share link"; "action_skip" = "Skip"; "action_start" = "Start"; "action_start_chat" = "Start chat"; @@ -214,6 +215,7 @@ "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_signout_preference_item" = "Sign out"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; +"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages."; "session_verification_banner_title" = "Access your message history"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Resources/Localizations/fr.lproj/Localizable.strings b/ElementX/Resources/Localizations/fr.lproj/Localizable.strings index f3769cf7e..f57223b3a 100644 --- a/ElementX/Resources/Localizations/fr.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/fr.lproj/Localizable.strings @@ -37,7 +37,8 @@ "action_save" = "Save"; "action_search" = "Search"; "action_send" = "Send"; -"action_share_link" = "Share"; +"action_share" = "Share"; +"action_share_link" = "Share link"; "action_skip" = "Skip"; "action_start" = "Start"; "action_start_chat" = "Start chat"; @@ -214,6 +215,7 @@ "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_signout_preference_item" = "Sign out"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; +"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages."; "session_verification_banner_title" = "Access your message history"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Resources/Localizations/it.lproj/Localizable.strings b/ElementX/Resources/Localizations/it.lproj/Localizable.strings index 6e7fc890d..03344d212 100644 --- a/ElementX/Resources/Localizations/it.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/it.lproj/Localizable.strings @@ -37,7 +37,8 @@ "action_save" = "Save"; "action_search" = "Search"; "action_send" = "Send"; -"action_share_link" = "Share"; +"action_share" = "Share"; +"action_share_link" = "Share link"; "action_skip" = "Skip"; "action_start" = "Start"; "action_start_chat" = "Start chat"; @@ -214,6 +215,7 @@ "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_signout_preference_item" = "Sign out"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; +"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages."; "session_verification_banner_title" = "Access your message history"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Resources/Localizations/ro.lproj/Localizable.strings b/ElementX/Resources/Localizations/ro.lproj/Localizable.strings index fa5fe6da6..a4878d578 100644 --- a/ElementX/Resources/Localizations/ro.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/ro.lproj/Localizable.strings @@ -37,6 +37,7 @@ "action_save" = "Salvează"; "action_search" = "Caută"; "action_send" = "Trimite"; +"action_share" = "Share"; "action_share_link" = "Partajează linkul"; "action_skip" = "Omite"; "action_start" = "Începe"; @@ -214,6 +215,7 @@ "screen_signout_in_progress_dialog_content" = "Deconectare în curs..."; "screen_signout_preference_item" = "Deconectează-te"; "screen_start_chat_error_starting_chat" = "A apărut o eroare la încercarea începerii conversației"; +"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Se pare că folosești un dispozitiv nou. Verifică-ți identitatea pentru acces la mesajele tale criptate."; "session_verification_banner_title" = "Accesează istoricul mesajelor"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 105108513..8569a7acf 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -89,6 +89,8 @@ public enum L10n { /// Send public static var actionSend: String { return L10n.tr("Localizable", "action_send") } /// Share + public static var actionShare: String { return L10n.tr("Localizable", "action_share") } + /// Share link public static var actionShareLink: String { return L10n.tr("Localizable", "action_share_link") } /// Skip public static var actionSkip: String { return L10n.tr("Localizable", "action_skip") } @@ -472,6 +474,8 @@ public enum L10n { public static var screenSignoutPreferenceItem: String { return L10n.tr("Localizable", "screen_signout_preference_item") } /// An error occurred when trying to start a chat public static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") } + /// We can’t validate this user’s Matrix ID. The invite might not be received. + public static var screenStartChatUnknownProfile: String { return L10n.tr("Localizable", "screen_start_chat_unknown_profile") } /// Looks like you’re using a new device. Verify it’s you to access your encrypted messages. public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") } /// Access your message history diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift index 3d3bf239f..e35fb0985 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift @@ -24,9 +24,6 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { var callback: ((StartChatViewModelAction) -> Void)? weak var userIndicatorController: UserIndicatorControllerProtocol? - var searchTask: Task? { - didSet { oldValue?.cancel() } - } init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) { self.userSession = userSession @@ -50,7 +47,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { case .selectUser(let user): showLoadingIndicator() Task { - let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(user.userID) + let currentDirectRoom = await clientProxy.directRoomForUserID(user.userID) switch currentDirectRoom { case .success(.some(let roomId)): self.hideLoadingIndicator() @@ -65,7 +62,9 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { } } - func displayError(_ type: ClientProxyError) { + // MARK: - Private + + private func displayError(_ type: ClientProxyError) { switch type { case .failedRetrievingDirectRoom: state.bindings.alertInfo = AlertInfo(id: type, @@ -80,8 +79,6 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { } } - // MARK: - Private - private func setupBindings() { context.$viewState .map(\.bindings.searchQuery) @@ -92,22 +89,56 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { } .switchToLatest() .removeDuplicates() - .sink { [weak self] query in - self?.updateState(searchQuery: query) + .sink { [weak self] _ in + self?.fetchData() } .store(in: &cancellables) } - private func updateState(searchQuery: String) { - searchTask = nil - - if searchQuery.count < 3 { + private func fetchData() { + guard searchQuery.count >= 3 else { fetchSuggestions() - } else if MatrixEntityRegex.isMatrixUserIdentifier(searchQuery) { - state.usersSection = .init(type: .searchResult, users: [UserProfile(userID: searchQuery)]) - } else { - searchUsers(searchTerm: searchQuery) + return } + + Task { + await searchProfiles() + } + } + + 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() { @@ -116,7 +147,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { private func createDirectRoom(with user: UserProfile) async { showLoadingIndicator() - let result = await userSession.clientProxy.createDirectRoom(with: user.userID) + let result = await clientProxy.createDirectRoom(with: user.userID) hideLoadingIndicator() switch result { case .success(let roomId): @@ -126,17 +157,20 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { } } - private func searchUsers(searchTerm: String) { - searchTask = Task { @MainActor in - guard - case let .success(result) = await userSession.clientProxy.searchUsers(searchTerm: searchTerm, limit: 5), - !Task.isCancelled - else { - return - } - - state.usersSection = .init(type: .searchResult, users: result.results) + 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 + } + + private var searchQuery: String { + context.searchQuery } // MARK: Loading indicator @@ -154,3 +188,18 @@ 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/StartChatViewModelProtocol.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift index c8ba19c74..7f538d14f 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift @@ -20,8 +20,4 @@ import Foundation protocol StartChatViewModelProtocol { var callback: ((StartChatViewModelAction) -> Void)? { get set } var context: StartChatViewModelType.Context { get } - - /// Display an error to the user. - /// - Parameter type: The type of error to be displayed. - func displayError(_ type: ClientProxyError) } diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift index 5b20d3799..65719b81e 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift @@ -29,15 +29,29 @@ struct StartChatSuggestedUserCell: View { imageProvider: imageProvider) .padding(.vertical, 10) .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { Text(user.displayName ?? user.userID) .font(.element.title3) .foregroundColor(.element.primaryContent) + if user.displayName != nil { Text(user.userID) .font(.element.subheadline) .foregroundColor(.element.tertiaryContent) } + + if !user.isVerified { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(Image(systemName: "exclamationmark.circle.fill")) + .foregroundColor(.compound.textActionCritical) + + Text(L10n.screenStartChatUnknownProfile) + .foregroundColor(.secondary) + } + .font(.compound.bodyXS) + .padding(.top, 4) + } } .accessibilityElement(children: .combine) } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 68c45fc6f..f81fa3014 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -258,6 +258,16 @@ class ClientProxy: ClientProxyProtocol { } } + func getProfile(for userID: String) async -> Result { + await Task.dispatch(on: clientQueue) { + do { + return try .success(.init(sdkUserProfile: self.client.getProfile(userId: userID))) + } catch { + return .failure(.failedGettingUserProfile) + } + } + } + // MARK: Private private func loadUserAvatarURLFromCache() { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 047ce6ddf..621e35467 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -32,6 +32,7 @@ enum ClientProxyError: Error { case failedRetrievingSessionVerificationController case failedLoadingMedia case failedSearchingUsers + case failedGettingUserProfile } enum SlidingSyncConstants { @@ -94,4 +95,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func setPusher(with configuration: PusherConfiguration) async throws func searchUsers(searchTerm: String, limit: UInt) async -> Result + + func getProfile(for userID: String) async -> Result } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 57378841c..29ee71ee6 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -115,4 +115,11 @@ class MockClientProxy: ClientProxyProtocol { func searchUsers(searchTerm: String, limit: UInt) async -> Result { searchUsersResult } + + var getProfileResult: Result = .success(.init(userID: "@a:b.com", displayName: "Some user")) + private(set) var getProfileCalled = false + func getProfile(for userID: String) async -> Result { + getProfileCalled = true + return getProfileResult + } } diff --git a/ElementX/Sources/Services/Users/UserProfile.swift b/ElementX/Sources/Services/Users/UserProfile.swift index 47f7d81e8..71bbe5a56 100644 --- a/ElementX/Sources/Services/Users/UserProfile.swift +++ b/ElementX/Sources/Services/Users/UserProfile.swift @@ -33,6 +33,12 @@ struct UserProfile { displayName = sdkUserProfile.displayName avatarURL = sdkUserProfile.avatarUrl.flatMap(URL.init(string:)) } + + /// A user is meant to be "verified" when the GET profile returns back either the display name or the avatar + /// If isn't we aren't sure that the related matrix id really exists. + var isVerified: Bool { + displayName != nil || avatarURL != nil + } } struct SearchUsersResults { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 7dcedf2a2..353ebd3da 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -322,6 +322,14 @@ class MockScreen: Identifiable { let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .startChatSearchingNonExistingID: + 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.setRootCoordinator(coordinator) + return navigationStackCoordinator case .roomMemberDetailsAccountOwner: let navigationStackCoordinator = NavigationStackCoordinator() let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockMe, mediaProvider: MockMediaProvider())) diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 7d1cb1e2d..b0156bda7 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -50,6 +50,7 @@ enum UITestsScreenIdentifier: String { case reportContent case startChat case startChatWithSearchResults + case startChatSearchingNonExistingID } extension UITestsScreenIdentifier: CustomStringConvertible { diff --git a/UITests/Sources/StartChatScreenUITests.swift b/UITests/Sources/StartChatScreenUITests.swift index 3ed43ef12..386e17f72 100644 --- a/UITests/Sources/StartChatScreenUITests.swift +++ b/UITests/Sources/StartChatScreenUITests.swift @@ -39,4 +39,22 @@ class StartChatScreenUITests: XCTestCase { XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 1) 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(.startChatSearchingInexistingID) + 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/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index 1017511c7..cfcc3827e 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -33,26 +33,84 @@ class StartChatScreenViewModelTests: XCTestCase { viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) } - func test_queryShowingNoResults() async throws { - viewModel.context.searchQuery = "A" + func testQueryShowingNoResults() async throws { + await search(query: "A") XCTAssertEqual(context.viewState.usersSection.type, .suggestions) - viewModel.context.searchQuery = "AA" + await search(query: "AA") XCTAssertEqual(context.viewState.usersSection.type, .suggestions) - viewModel.context.searchQuery = "AAA" - _ = await context.$viewState.nextValue - XCTAssertEqual(context.viewState.usersSection.type, .searchResult) - XCTAssert(context.viewState.hasEmptySearchResults) + await search(query: "AAA") + assertSearchResults(toBe: 0) } - func test_queryShowingResults() async throws { + func testQueryShowingResults() async throws { clientProxy.searchUsersResult = .success(.init(results: [UserProfile.mockAlice], limited: true)) - viewModel.context.searchQuery = "AAA" - _ = await context.$viewState.nextValue + 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) + } + + // MARK: - Private + + private func assertSearchResults(toBe count: Int) { + XCTAssertTrue(count >= 0) XCTAssertEqual(context.viewState.usersSection.type, .searchResult) - XCTAssertEqual(context.viewState.usersSection.users.count, 1) - XCTAssertFalse(context.viewState.hasEmptySearchResults) + XCTAssertEqual(context.viewState.usersSection.users.count, count) + XCTAssertEqual(context.viewState.hasEmptySearchResults, count == 0) + } + + @discardableResult + private func search(query: String) async -> StartChatViewState? { + viewModel.context.searchQuery = query + return await context.$viewState.nextValue + } + + private var searchResults: [UserProfile] { + [ + .mockAlice, + .mockBob, + .mockCharlie + ] } }