diff --git a/Dangerfile.swift b/Dangerfile.swift index ec1a765de..85e4692fb 100644 --- a/Dangerfile.swift +++ b/Dangerfile.swift @@ -51,7 +51,8 @@ let allowList = ["stefanceriu", "phlniji", "aringenbach", "flescio", - "Velin92"] + "Velin92", + "alfogrillo"] let requiresSignOff = !allowList.contains(where: { $0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame diff --git a/ElementX/Resources/Localizations/de.lproj/Localizable.strings b/ElementX/Resources/Localizations/de.lproj/Localizable.strings index b4c680caf..f028b0342 100644 --- a/ElementX/Resources/Localizations/de.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/de.lproj/Localizable.strings @@ -62,6 +62,7 @@ "common_message_layout" = "Message layout"; "common_message_removed" = "Message removed"; "common_modern" = "Modern"; +"common_no_results" = "No results"; "common_offline" = "Offline"; "common_password" = "Password"; "common_people" = "People"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 639b68121..b30ceff83 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -62,6 +62,7 @@ "common_message_layout" = "Message layout"; "common_message_removed" = "Message removed"; "common_modern" = "Modern"; +"common_no_results" = "No results"; "common_offline" = "Offline"; "common_password" = "Password"; "common_people" = "People"; diff --git a/ElementX/Resources/Localizations/fr.lproj/Localizable.strings b/ElementX/Resources/Localizations/fr.lproj/Localizable.strings index e345d5d15..618a3808a 100644 --- a/ElementX/Resources/Localizations/fr.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/fr.lproj/Localizable.strings @@ -62,6 +62,7 @@ "common_message_layout" = "Message layout"; "common_message_removed" = "Message removed"; "common_modern" = "Modern"; +"common_no_results" = "No results"; "common_offline" = "Offline"; "common_password" = "Password"; "common_people" = "People"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 753a736dc..fb51b0494 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -142,6 +142,8 @@ public enum L10n { public static var commonMessageRemoved: String { return L10n.tr("Localizable", "common_message_removed") } /// Modern public static var commonModern: String { return L10n.tr("Localizable", "common_modern") } + /// No results + public static var commonNoResults: String { return L10n.tr("Localizable", "common_no_results") } /// Offline public static var commonOffline: String { return L10n.tr("Localizable", "common_offline") } /// Password diff --git a/ElementX/Sources/Mocks/UserProfileMock.swift b/ElementX/Sources/Mocks/UserProfile+Mock.swift similarity index 85% rename from ElementX/Sources/Mocks/UserProfileMock.swift rename to ElementX/Sources/Mocks/UserProfile+Mock.swift index d06f07971..6eeb638bc 100644 --- a/ElementX/Sources/Mocks/UserProfileMock.swift +++ b/ElementX/Sources/Mocks/UserProfile+Mock.swift @@ -16,17 +16,17 @@ import Foundation -extension UserProfileProxy { +extension UserProfile { // Mocks - static var mockAlice: UserProfileProxy { + static var mockAlice: UserProfile { .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: URL(staticString: "mxc://matrix.org/UcCimidcvpFvWkPzvjXMQPHA")) } - static var mockBob: UserProfileProxy { + static var mockBob: UserProfile { .init(userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil) } - static var mockCharlie: UserProfileProxy { + static var mockCharlie: UserProfile { .init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil) } } diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index d26affb80..c8d5b0b9f 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -101,6 +101,7 @@ struct A11yIdentifiers { struct StartChatScreen { let closeStartChat = "start_chat-close" let inviteFriends = "start_chat-invite_friends" + let searchNoResults = "start_chat-search_no_results" } struct RoomMemberDetailsScreen { diff --git a/ElementX/Sources/Other/Extensions/Publisher.swift b/ElementX/Sources/Other/Extensions/Publisher.swift index 274f5921e..ebd939558 100644 --- a/ElementX/Sources/Other/Extensions/Publisher.swift +++ b/ElementX/Sources/Other/Extensions/Publisher.swift @@ -23,3 +23,17 @@ extension Publisher where Self.Failure == Never { } } } + +extension Published.Publisher { + /// Returns the next output from the publisher skipping the current value stored into it (which is readable from the @Published property itself). + /// - Returns: the next output from the publisher + var nextValue: Output? { + get async { + var iterator = values.makeAsyncIterator() + + // skips the publisher's current value + _ = await iterator.next() + return await iterator.next() + } + } +} diff --git a/ElementX/Sources/Screens/StartChat/StartChatModels.swift b/ElementX/Sources/Screens/StartChat/StartChatModels.swift index ec1a7a94f..b2a44e72a 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatModels.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatModels.swift @@ -24,18 +24,21 @@ enum StartChatViewModelAction { struct StartChatViewState: BindableState { var bindings = StartChatScreenViewStateBindings() - + var usersSection: StartChatUsersSection = .init(type: .suggestions, users: []) + var isSearching: Bool { !bindings.searchQuery.isEmpty } - var usersSection: StartChatUsersSection = .init(type: .suggestions, users: []) + var hasEmptySearchResults: Bool { + isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty + } } -enum StartChatUserSectionType { +enum StartChatUserSectionType: Equatable { case searchResult case suggestions - + var title: String? { switch self { case .searchResult: @@ -47,8 +50,8 @@ enum StartChatUserSectionType { } struct StartChatUsersSection { - var type: StartChatUserSectionType - var users: [UserProfileProxy] + let type: StartChatUserSectionType + let users: [UserProfile] } struct StartChatScreenViewStateBindings { @@ -62,5 +65,5 @@ enum StartChatViewAction { case close case createRoom case inviteFriends - case selectUser(UserProfileProxy) + case selectUser(UserProfile) } diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift index 7e6b6a3b3..3d3bf239f 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift @@ -24,6 +24,9 @@ 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 @@ -31,7 +34,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider) setupBindings() - fetchSuggestion() + fetchSuggestions() } // MARK: - Public @@ -82,28 +85,36 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { private func setupBindings() { context.$viewState .map(\.bindings.searchQuery) - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .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() - .sink { [weak self] searchQuery in - if searchQuery.isEmpty { - self?.fetchSuggestion() - } else if MatrixEntityRegex.isMatrixUserIdentifier(searchQuery) { - self?.state.usersSection.type = .searchResult - self?.state.usersSection.users = [UserProfileProxy(userID: searchQuery, displayName: nil, avatarURL: nil)] - } else { - self?.state.usersSection.type = .searchResult - self?.state.usersSection.users = [] - } + .sink { [weak self] query in + self?.updateState(searchQuery: query) } .store(in: &cancellables) } - private func fetchSuggestion() { - state.usersSection.type = .suggestions - state.usersSection.users = [.mockAlice, .mockBob, .mockCharlie] + private func updateState(searchQuery: String) { + searchTask = nil + + if searchQuery.count < 3 { + fetchSuggestions() + } else if MatrixEntityRegex.isMatrixUserIdentifier(searchQuery) { + state.usersSection = .init(type: .searchResult, users: [UserProfile(userID: searchQuery)]) + } else { + searchUsers(searchTerm: searchQuery) + } } - private func createDirectRoom(with user: UserProfileProxy) async { + private func fetchSuggestions() { + state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) + } + + private func createDirectRoom(with user: UserProfile) async { showLoadingIndicator() let result = await userSession.clientProxy.createDirectRoom(with: user.userID) hideLoadingIndicator() @@ -115,6 +126,19 @@ 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) + } + } + // MARK: Loading indicator static let loadingIndicatorIdentifier = "StartChatLoading" diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift index ca8d6b849..dc5b7c07e 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift @@ -22,10 +22,10 @@ struct StartChatScreen: View { var body: some View { Form { if !context.viewState.isSearching { - createRoomSection - inviteFriendsSection + mainContent + } else { + searchContent } - usersSection } .scrollContentBackground(.hidden) .background(Color.element.formBackground.ignoresSafeArea()) @@ -39,6 +39,26 @@ struct StartChatScreen: View { .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone) .alert(item: $context.alertInfo) { $0.alert } } + + // MARK: - Private + + /// The content shown in the form when the search query is empty. + @ViewBuilder + private var mainContent: some View { + createRoomSection + inviteFriendsSection + usersSection + } + + /// 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 { + usersSection + } + } private var createRoomSection: some View { Section { @@ -77,6 +97,15 @@ struct StartChatScreen: View { .formSectionStyle() } + private var noResultsContent: some View { + Text(L10n.commonNoResults) + .font(.element.body) + .foregroundColor(.element.tertiaryContent) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier(A11yIdentifiers.startChatScreen.searchNoResults) + } + private var closeButton: some View { Button(L10n.actionCancel, action: close) .accessibilityIdentifier(A11yIdentifiers.startChatScreen.closeStartChat) diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift index d32a55e46..5b20d3799 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift @@ -17,7 +17,7 @@ import SwiftUI struct StartChatSuggestedUserCell: View { - let user: UserProfileProxy + let user: UserProfile let imageProvider: ImageProviderProtocol? var body: some View { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index b9f6598d1..34be78fd1 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -248,6 +248,16 @@ class ClientProxy: ClientProxyProtocol { } } + func searchUsers(searchTerm: String, limit: UInt) async -> Result { + await Task.dispatch(on: clientQueue) { + do { + return try .success(.init(sdkResults: self.client.searchUsers(searchTerm: searchTerm, limit: UInt64(limit)))) + } catch { + return .failure(.failedSearchingUsers) + } + } + } + // MARK: Private private func loadUserAvatarURLFromCache() { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 7f0488e69..047ce6ddf 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -31,6 +31,7 @@ enum ClientProxyError: Error { case failedSettingAccountData case failedRetrievingSessionVerificationController case failedLoadingMedia + case failedSearchingUsers } enum SlidingSyncConstants { @@ -91,4 +92,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func logout() async func setPusher(with configuration: PusherConfiguration) async throws + + func searchUsers(searchTerm: String, limit: UInt) async -> Result } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 5c51a9a61..57378841c 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -110,4 +110,9 @@ class MockClientProxy: ClientProxyProtocol { setPusherCalled = true setPusherArgument = configuration } + + var searchUsersResult: Result = .success(.init(results: [], limited: false)) + func searchUsers(searchTerm: String, limit: UInt) async -> Result { + searchUsersResult + } } diff --git a/ElementX/Sources/Services/Users/UserProfile.swift b/ElementX/Sources/Services/Users/UserProfile.swift index 6c61f40d0..47f7d81e8 100644 --- a/ElementX/Sources/Services/Users/UserProfile.swift +++ b/ElementX/Sources/Services/Users/UserProfile.swift @@ -17,20 +17,32 @@ import Foundation import MatrixRustSDK -struct UserProfileProxy { +struct UserProfile { let userID: String let displayName: String? let avatarURL: URL? - init(userID: String, displayName: String?, avatarURL: URL?) { + init(userID: String, displayName: String? = nil, avatarURL: URL? = nil) { self.userID = userID self.displayName = displayName self.avatarURL = avatarURL } - init(userProfile: UserProfile) { - userID = userProfile.userId - displayName = userProfile.displayName - avatarURL = userProfile.avatarUrl.flatMap(URL.init(string:)) + init(sdkUserProfile: MatrixRustSDK.UserProfile) { + userID = sdkUserProfile.userId + displayName = sdkUserProfile.displayName + avatarURL = sdkUserProfile.avatarUrl.flatMap(URL.init(string:)) + } +} + +struct SearchUsersResults { + let results: [UserProfile] + let limited: Bool +} + +extension SearchUsersResults { + init(sdkResults: MatrixRustSDK.SearchUsersResults) { + results = sdkResults.results.map(UserProfile.init) + limited = sdkResults.limited } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b928a5c75..612f7d34f 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -313,6 +313,13 @@ class MockScreen: Identifiable { let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()))) 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.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 adf9b0ac4..9b50c6e48 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -48,6 +48,7 @@ enum UITestsScreenIdentifier: String { case roomMemberDetailsIgnoredUser case reportContent case startChat + case startChatWithSearchResults } extension UITestsScreenIdentifier: CustomStringConvertible { diff --git a/UITests/Sources/StartChatScreenUITests.swift b/UITests/Sources/StartChatScreenUITests.swift index 1fba9b79f..3ed43ef12 100644 --- a/UITests/Sources/StartChatScreenUITests.swift +++ b/UITests/Sources/StartChatScreenUITests.swift @@ -18,8 +18,25 @@ import ElementX import XCTest class StartChatScreenUITests: XCTestCase { - func testStartChatScreen() { + func testLanding() { let app = Application.launch(.startChat) app.assertScreenshot(.startChat) } + + func testSearchWithNoResults() { + let app = Application.launch(.startChat) + let searchField = app.searchFields.firstMatch + searchField.clearAndTypeText("Someone") + XCTAssert(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) + app.assertScreenshot(.startChat, step: 1) + } + + func testSearchWithResults() { + let app = Application.launch(.startChatWithSearchResults) + let searchField = app.searchFields.firstMatch + searchField.clearAndTypeText("Someone") + XCTAssertFalse(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0)) + XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 1) + app.assertScreenshot(.startChat, step: 2) + } } diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index 25ffb8b68..1017511c7 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -21,12 +21,38 @@ import XCTest @MainActor class StartChatScreenViewModelTests: XCTestCase { var viewModel: StartChatViewModelProtocol! - var context: StartChatViewModelType.Context! + var clientProxy: MockClientProxy! - @MainActor override func setUpWithError() throws { - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""), - mediaProvider: MockMediaProvider()) + var context: StartChatViewModel.Context { + viewModel.context + } + + override func setUpWithError() throws { + clientProxy = .init(userID: "") + let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) - context = viewModel.context + } + + func test_queryShowingNoResults() async throws { + viewModel.context.searchQuery = "A" + XCTAssertEqual(context.viewState.usersSection.type, .suggestions) + + viewModel.context.searchQuery = "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) + } + + func test_queryShowingResults() async throws { + clientProxy.searchUsersResult = .success(.init(results: [UserProfile.mockAlice], limited: true)) + + viewModel.context.searchQuery = "AAA" + _ = await context.$viewState.nextValue + XCTAssertEqual(context.viewState.usersSection.type, .searchResult) + XCTAssertEqual(context.viewState.usersSection.users.count, 1) + XCTAssertFalse(context.viewState.hasEmptySearchResults) } } diff --git a/UnitTests/SupportingFiles/target.yml b/UnitTests/SupportingFiles/target.yml index db013a09d..378229bea 100644 --- a/UnitTests/SupportingFiles/target.yml +++ b/UnitTests/SupportingFiles/target.yml @@ -50,3 +50,4 @@ targets: - path: ../../Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit - path: ../Resources - path: ../../ElementX/Sources/Other/InfoPlistReader.swift + - path: ../../ElementX/Sources/Other/Extensions/Publisher.swift diff --git a/changelog.d/593.feature b/changelog.d/593.feature new file mode 100644 index 000000000..6afb1681f --- /dev/null +++ b/changelog.d/593.feature @@ -0,0 +1 @@ +Add user search when creating a new dm room.