Add user search for dm chats (#739)

* Add searchUsers in ClientProxy

* Add UserSearchProtocol

* Delete UserSearch file

* Add search

* Refine StartChatScreen

* Improve StartChatViewModel

* Add localizations

* Fix no result style

* Update localizations

* Add UTs

* Add UI tests

* Cleanup

* Refine tests

* Add changelog.d file

* Naming refactor

* Refactor ClientProxyProtocol api

* Fix typo

* Add mark

* Rename tests

* Update Dangerfile

* Improve UI test code

* Refactor search api

* Improve style

* Improve combine chain

* Add comment

* Improve StartChatScreen

* Improve updateState

* Add extension Published.Publisher

* Improve UI tests

* Remove Combine import

* Cleanup

* Remove “proxy” wording

* Delete extra extensions

* Refactor Publisher api
This commit is contained in:
Alfonso Grillo
2023-03-29 14:29:25 +02:00
committed by GitHub
parent f67207616b
commit e15b36b916
22 changed files with 204 additions and 44 deletions
@@ -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)
}
@@ -24,6 +24,9 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
var callback: ((StartChatViewModelAction) -> Void)?
weak var userIndicatorController: UserIndicatorControllerProtocol?
var searchTask: Task<Void, Error>? {
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"
@@ -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)
@@ -17,7 +17,7 @@
import SwiftUI
struct StartChatSuggestedUserCell: View {
let user: UserProfileProxy
let user: UserProfile
let imageProvider: ImageProviderProtocol?
var body: some View {