mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user