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 <alfogrillo@gmail.com>

* 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 <alfogrillo@gmail.com>

* Update ElementX/Sources/Services/Users/UserDiscoveryService.swift

Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>

* fix invite users and start chat errors

* use only one task to fetch user profile

---------

Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>
Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Flescio 2023-04-21 10:11:15 +02:00 committed by GitHub
parent b564036b68
commit 88d3faf77d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 549 additions and 236 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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<S: Sendable, F: Error> {
private var storedValue: Task<S, F>?
init(_ value: Task<S, F>? = nil) {
storedValue = value
}
var wrappedValue: Task<S, F>? {
get {
storedValue
} set {
storedValue?.cancel()
storedValue = newValue
}
}
}

View File

@ -15,6 +15,7 @@
//
import Combine
import Foundation
extension Publisher where Self.Failure == Never {
func weakAssign<Root: AnyObject>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, 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<String, Never> {
map { query in
let milliseconds = query.isEmpty ? 0 : 500
return Just(query).delay(for: .milliseconds(milliseconds), scheduler: DispatchQueue.main)
}
.switchToLatest()
.removeDuplicates()
.eraseToAnyPublisher()
}
}

View File

@ -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() {

View File

@ -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<ClientProxyError>?
var alertInfo: AlertInfo<InviteUsersErrorType>?
}
enum InviteUsersViewAction {

View File

@ -21,17 +21,19 @@ typealias InviteUsersViewModelType = StateStoreViewModel<InviteUsersViewState, I
class InviteUsersViewModel: InviteUsersViewModelType, InviteUsersViewModelProtocol {
private let userSession: UserSessionProtocol
private let userDiscoveryService: UserDiscoveryServiceProtocol
private let actionsSubject: PassthroughSubject<InviteUsersViewModelAction, Never> = .init()
var actions: AnyPublisher<InviteUsersViewModelAction, Never> {
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<Void, Never>?
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
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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<ClientProxyError>?
var alertInfo: AlertInfo<StartChatErrorType>?
}
enum StartChatViewAction {

View File

@ -22,6 +22,7 @@ typealias StartChatViewModelType = StateStoreViewModel<StartChatViewState, Start
class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
private let userSession: UserSessionProtocol
private let actionsSubject: PassthroughSubject<StartChatViewModelAction, Never> = .init()
private let userDiscoveryService: UserDiscoveryServiceProtocol
var actions: AnyPublisher<StartChatViewModelAction, Never> {
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<Void, Never>?
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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -262,7 +262,7 @@ class ClientProxy: ClientProxyProtocol {
}
}
func getProfile(for userID: String) async -> Result<UserProfile, ClientProxyError> {
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError> {
await Task.dispatch(on: clientQueue) {
do {
return try .success(.init(sdkUserProfile: self.client.getProfile(userId: userID)))

View File

@ -98,5 +98,5 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func searchUsers(searchTerm: String, limit: UInt) async -> Result<SearchUsersResults, ClientProxyError>
func getProfile(for userID: String) async -> Result<UserProfile, ClientProxyError>
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
}

View File

@ -125,7 +125,7 @@ class MockClientProxy: ClientProxyProtocol {
var getProfileResult: Result<UserProfile, ClientProxyError> = .success(.init(userID: "@a:b.com", displayName: "Some user"))
private(set) var getProfileCalled = false
func getProfile(for userID: String) async -> Result<UserProfile, ClientProxyError> {
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError> {
getProfileCalled = true
return getProfileResult
}

View File

@ -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 }

View File

@ -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<UserProfile, ClientProxyError> {
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)
}
}

View File

@ -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>
}

View File

@ -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
}

View File

@ -51,7 +51,6 @@ enum UITestsScreenIdentifier: String {
case reportContent
case startChat
case startChatWithSearchResults
case startChatSearchingNonexistentID
case invites
case invitesNoInvites
case inviteUsers

View File

@ -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)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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())
}
}

View File

@ -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
}

View File

@ -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
]
}
}

View File

@ -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
]
}
}

1
changelog.d/789.change Normal file
View File

@ -0,0 +1 @@
Move search users into UserProvider service