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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 204 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@
import SwiftUI
struct StartChatSuggestedUserCell: View {
let user: UserProfileProxy
let user: UserProfile
let imageProvider: ImageProviderProtocol?
var body: some View {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ enum UITestsScreenIdentifier: String {
case roomMemberDetailsIgnoredUser
case reportContent
case startChat
case startChatWithSearchResults
}
extension UITestsScreenIdentifier: CustomStringConvertible {

View File

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

View File

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

View File

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

1
changelog.d/593.feature Normal file
View File

@ -0,0 +1 @@
Add user search when creating a new dm room.