mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +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:
+2
-1
@@ -51,7 +51,8 @@ let allowList = ["stefanceriu",
|
|||||||
"phlniji",
|
"phlniji",
|
||||||
"aringenbach",
|
"aringenbach",
|
||||||
"flescio",
|
"flescio",
|
||||||
"Velin92"]
|
"Velin92",
|
||||||
|
"alfogrillo"]
|
||||||
|
|
||||||
let requiresSignOff = !allowList.contains(where: {
|
let requiresSignOff = !allowList.contains(where: {
|
||||||
$0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame
|
$0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"common_message_layout" = "Message layout";
|
"common_message_layout" = "Message layout";
|
||||||
"common_message_removed" = "Message removed";
|
"common_message_removed" = "Message removed";
|
||||||
"common_modern" = "Modern";
|
"common_modern" = "Modern";
|
||||||
|
"common_no_results" = "No results";
|
||||||
"common_offline" = "Offline";
|
"common_offline" = "Offline";
|
||||||
"common_password" = "Password";
|
"common_password" = "Password";
|
||||||
"common_people" = "People";
|
"common_people" = "People";
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"common_message_layout" = "Message layout";
|
"common_message_layout" = "Message layout";
|
||||||
"common_message_removed" = "Message removed";
|
"common_message_removed" = "Message removed";
|
||||||
"common_modern" = "Modern";
|
"common_modern" = "Modern";
|
||||||
|
"common_no_results" = "No results";
|
||||||
"common_offline" = "Offline";
|
"common_offline" = "Offline";
|
||||||
"common_password" = "Password";
|
"common_password" = "Password";
|
||||||
"common_people" = "People";
|
"common_people" = "People";
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"common_message_layout" = "Message layout";
|
"common_message_layout" = "Message layout";
|
||||||
"common_message_removed" = "Message removed";
|
"common_message_removed" = "Message removed";
|
||||||
"common_modern" = "Modern";
|
"common_modern" = "Modern";
|
||||||
|
"common_no_results" = "No results";
|
||||||
"common_offline" = "Offline";
|
"common_offline" = "Offline";
|
||||||
"common_password" = "Password";
|
"common_password" = "Password";
|
||||||
"common_people" = "People";
|
"common_people" = "People";
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ public enum L10n {
|
|||||||
public static var commonMessageRemoved: String { return L10n.tr("Localizable", "common_message_removed") }
|
public static var commonMessageRemoved: String { return L10n.tr("Localizable", "common_message_removed") }
|
||||||
/// Modern
|
/// Modern
|
||||||
public static var commonModern: String { return L10n.tr("Localizable", "common_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
|
/// Offline
|
||||||
public static var commonOffline: String { return L10n.tr("Localizable", "common_offline") }
|
public static var commonOffline: String { return L10n.tr("Localizable", "common_offline") }
|
||||||
/// Password
|
/// Password
|
||||||
|
|||||||
+4
-4
@@ -16,17 +16,17 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension UserProfileProxy {
|
extension UserProfile {
|
||||||
// Mocks
|
// Mocks
|
||||||
static var mockAlice: UserProfileProxy {
|
static var mockAlice: UserProfile {
|
||||||
.init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: URL(staticString: "mxc://matrix.org/UcCimidcvpFvWkPzvjXMQPHA"))
|
.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)
|
.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)
|
.init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +101,7 @@ struct A11yIdentifiers {
|
|||||||
struct StartChatScreen {
|
struct StartChatScreen {
|
||||||
let closeStartChat = "start_chat-close"
|
let closeStartChat = "start_chat-close"
|
||||||
let inviteFriends = "start_chat-invite_friends"
|
let inviteFriends = "start_chat-invite_friends"
|
||||||
|
let searchNoResults = "start_chat-search_no_results"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomMemberDetailsScreen {
|
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,15 +24,18 @@ enum StartChatViewModelAction {
|
|||||||
|
|
||||||
struct StartChatViewState: BindableState {
|
struct StartChatViewState: BindableState {
|
||||||
var bindings = StartChatScreenViewStateBindings()
|
var bindings = StartChatScreenViewStateBindings()
|
||||||
|
var usersSection: StartChatUsersSection = .init(type: .suggestions, users: [])
|
||||||
|
|
||||||
var isSearching: Bool {
|
var isSearching: Bool {
|
||||||
!bindings.searchQuery.isEmpty
|
!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 searchResult
|
||||||
case suggestions
|
case suggestions
|
||||||
|
|
||||||
@@ -47,8 +50,8 @@ enum StartChatUserSectionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct StartChatUsersSection {
|
struct StartChatUsersSection {
|
||||||
var type: StartChatUserSectionType
|
let type: StartChatUserSectionType
|
||||||
var users: [UserProfileProxy]
|
let users: [UserProfile]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StartChatScreenViewStateBindings {
|
struct StartChatScreenViewStateBindings {
|
||||||
@@ -62,5 +65,5 @@ enum StartChatViewAction {
|
|||||||
case close
|
case close
|
||||||
case createRoom
|
case createRoom
|
||||||
case inviteFriends
|
case inviteFriends
|
||||||
case selectUser(UserProfileProxy)
|
case selectUser(UserProfile)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
|
|||||||
|
|
||||||
var callback: ((StartChatViewModelAction) -> Void)?
|
var callback: ((StartChatViewModelAction) -> Void)?
|
||||||
weak var userIndicatorController: UserIndicatorControllerProtocol?
|
weak var userIndicatorController: UserIndicatorControllerProtocol?
|
||||||
|
var searchTask: Task<Void, Error>? {
|
||||||
|
didSet { oldValue?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) {
|
init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) {
|
||||||
self.userSession = userSession
|
self.userSession = userSession
|
||||||
@@ -31,7 +34,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
|
|||||||
super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider)
|
super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider)
|
||||||
|
|
||||||
setupBindings()
|
setupBindings()
|
||||||
fetchSuggestion()
|
fetchSuggestions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -82,28 +85,36 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
|
|||||||
private func setupBindings() {
|
private func setupBindings() {
|
||||||
context.$viewState
|
context.$viewState
|
||||||
.map(\.bindings.searchQuery)
|
.map(\.bindings.searchQuery)
|
||||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
.map { query in
|
||||||
.removeDuplicates()
|
// debounce search queries but make sure clearing the search updates immediately
|
||||||
.sink { [weak self] searchQuery in
|
let milliseconds = query.isEmpty ? 0 : 500
|
||||||
if searchQuery.isEmpty {
|
return Just(query).delay(for: .milliseconds(milliseconds), scheduler: DispatchQueue.main)
|
||||||
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 = []
|
|
||||||
}
|
}
|
||||||
|
.switchToLatest()
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] query in
|
||||||
|
self?.updateState(searchQuery: query)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchSuggestion() {
|
private func updateState(searchQuery: String) {
|
||||||
state.usersSection.type = .suggestions
|
searchTask = nil
|
||||||
state.usersSection.users = [.mockAlice, .mockBob, .mockCharlie]
|
|
||||||
|
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()
|
showLoadingIndicator()
|
||||||
let result = await userSession.clientProxy.createDirectRoom(with: user.userID)
|
let result = await userSession.clientProxy.createDirectRoom(with: user.userID)
|
||||||
hideLoadingIndicator()
|
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
|
// MARK: Loading indicator
|
||||||
|
|
||||||
static let loadingIndicatorIdentifier = "StartChatLoading"
|
static let loadingIndicatorIdentifier = "StartChatLoading"
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ struct StartChatScreen: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if !context.viewState.isSearching {
|
if !context.viewState.isSearching {
|
||||||
createRoomSection
|
mainContent
|
||||||
inviteFriendsSection
|
} else {
|
||||||
|
searchContent
|
||||||
}
|
}
|
||||||
usersSection
|
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.element.formBackground.ignoresSafeArea())
|
.background(Color.element.formBackground.ignoresSafeArea())
|
||||||
@@ -40,6 +40,26 @@ struct StartChatScreen: View {
|
|||||||
.alert(item: $context.alertInfo) { $0.alert }
|
.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 {
|
private var createRoomSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Button(action: createRoom) {
|
Button(action: createRoom) {
|
||||||
@@ -77,6 +97,15 @@ struct StartChatScreen: View {
|
|||||||
.formSectionStyle()
|
.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 {
|
private var closeButton: some View {
|
||||||
Button(L10n.actionCancel, action: close)
|
Button(L10n.actionCancel, action: close)
|
||||||
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.closeStartChat)
|
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.closeStartChat)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StartChatSuggestedUserCell: View {
|
struct StartChatSuggestedUserCell: View {
|
||||||
let user: UserProfileProxy
|
let user: UserProfile
|
||||||
let imageProvider: ImageProviderProtocol?
|
let imageProvider: ImageProviderProtocol?
|
||||||
|
|
||||||
var body: some View {
|
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
|
// MARK: Private
|
||||||
|
|
||||||
private func loadUserAvatarURLFromCache() {
|
private func loadUserAvatarURLFromCache() {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum ClientProxyError: Error {
|
|||||||
case failedSettingAccountData
|
case failedSettingAccountData
|
||||||
case failedRetrievingSessionVerificationController
|
case failedRetrievingSessionVerificationController
|
||||||
case failedLoadingMedia
|
case failedLoadingMedia
|
||||||
|
case failedSearchingUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SlidingSyncConstants {
|
enum SlidingSyncConstants {
|
||||||
@@ -91,4 +92,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
|||||||
func logout() async
|
func logout() async
|
||||||
|
|
||||||
func setPusher(with configuration: PusherConfiguration) async throws
|
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
|
setPusherCalled = true
|
||||||
setPusherArgument = configuration
|
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 Foundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
struct UserProfileProxy {
|
struct UserProfile {
|
||||||
let userID: String
|
let userID: String
|
||||||
let displayName: String?
|
let displayName: String?
|
||||||
let avatarURL: URL?
|
let avatarURL: URL?
|
||||||
|
|
||||||
init(userID: String, displayName: String?, avatarURL: URL?) {
|
init(userID: String, displayName: String? = nil, avatarURL: URL? = nil) {
|
||||||
self.userID = userID
|
self.userID = userID
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.avatarURL = avatarURL
|
self.avatarURL = avatarURL
|
||||||
}
|
}
|
||||||
|
|
||||||
init(userProfile: UserProfile) {
|
init(sdkUserProfile: MatrixRustSDK.UserProfile) {
|
||||||
userID = userProfile.userId
|
userID = sdkUserProfile.userId
|
||||||
displayName = userProfile.displayName
|
displayName = sdkUserProfile.displayName
|
||||||
avatarURL = userProfile.avatarUrl.flatMap(URL.init(string:))
|
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())))
|
let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider())))
|
||||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||||
return navigationStackCoordinator
|
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:
|
case .roomMemberDetailsAccountOwner:
|
||||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||||
let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockMe, mediaProvider: MockMediaProvider()))
|
let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockMe, mediaProvider: MockMediaProvider()))
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ enum UITestsScreenIdentifier: String {
|
|||||||
case roomMemberDetailsIgnoredUser
|
case roomMemberDetailsIgnoredUser
|
||||||
case reportContent
|
case reportContent
|
||||||
case startChat
|
case startChat
|
||||||
|
case startChatWithSearchResults
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UITestsScreenIdentifier: CustomStringConvertible {
|
extension UITestsScreenIdentifier: CustomStringConvertible {
|
||||||
|
|||||||
@@ -18,8 +18,25 @@ import ElementX
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
class StartChatScreenUITests: XCTestCase {
|
class StartChatScreenUITests: XCTestCase {
|
||||||
func testStartChatScreen() {
|
func testLanding() {
|
||||||
let app = Application.launch(.startChat)
|
let app = Application.launch(.startChat)
|
||||||
app.assertScreenshot(.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
|
@MainActor
|
||||||
class StartChatScreenViewModelTests: XCTestCase {
|
class StartChatScreenViewModelTests: XCTestCase {
|
||||||
var viewModel: StartChatViewModelProtocol!
|
var viewModel: StartChatViewModelProtocol!
|
||||||
var context: StartChatViewModelType.Context!
|
var clientProxy: MockClientProxy!
|
||||||
|
|
||||||
@MainActor override func setUpWithError() throws {
|
var context: StartChatViewModel.Context {
|
||||||
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""),
|
viewModel.context
|
||||||
mediaProvider: MockMediaProvider())
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
clientProxy = .init(userID: "")
|
||||||
|
let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
|
||||||
viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil)
|
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: ../../Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit
|
||||||
- path: ../Resources
|
- path: ../Resources
|
||||||
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
- 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.
|
||||||
Reference in New Issue
Block a user