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:
parent
f67207616b
commit
e15b36b916
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -248,6 +248,16 @@ class ClientProxy: ClientProxyProtocol {
|
|||
}
|
||||
}
|
||||
|
||||
func searchUsers(searchTerm: String, limit: UInt) async -> Result<SearchUsersResults, ClientProxyError> {
|
||||
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() {
|
||||
|
|
|
@ -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<SearchUsersResults, ClientProxyError>
|
||||
}
|
||||
|
|
|
@ -110,4 +110,9 @@ class MockClientProxy: ClientProxyProtocol {
|
|||
setPusherCalled = true
|
||||
setPusherArgument = configuration
|
||||
}
|
||||
|
||||
var searchUsersResult: Result<SearchUsersResults, ClientProxyError> = .success(.init(results: [], limited: false))
|
||||
func searchUsers(searchTerm: String, limit: UInt) async -> Result<SearchUsersResults, ClientProxyError> {
|
||||
searchUsersResult
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -48,6 +48,7 @@ enum UITestsScreenIdentifier: String {
|
|||
case roomMemberDetailsIgnoredUser
|
||||
case reportContent
|
||||
case startChat
|
||||
case startChatWithSearchResults
|
||||
}
|
||||
|
||||
extension UITestsScreenIdentifier: CustomStringConvertible {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Add user search when creating a new dm room.
|
Loading…
Reference in New Issue