From d344a20d2e19f91b099f8ddd04636a7989e1533f Mon Sep 17 00:00:00 2001 From: Flescio Date: Fri, 24 Mar 2023 16:16:07 +0100 Subject: [PATCH] Show or create direct message room (#716) * add start chat flow with UI * add feature flag for start chat * add changelog * fix naming and tests * fix empty display name in user cell * Update ElementX/Sources/Application/AppSettings.swift Co-authored-by: Alfonso Grillo * add screenshots from UI test * fix swiftFormat and add identifiers * fix warnings * add the create or open already existing direct room feature * add changelog * add UserProfile object, add loading indicator in start chat flow * fix test * add new section for user profile in start chat * fix duplicates input on search bar * Update ElementX/Sources/Services/Client/ClientProxy.swift Co-authored-by: Alfonso Grillo * Update ElementX/Sources/Services/Client/ClientProxy.swift Co-authored-by: Alfonso Grillo --------- Co-authored-by: Alfonso Grillo --- .../en.lproj/Untranslated.strings | 2 + .../Generated/Strings+Untranslated.swift | 2 + ElementX/Sources/Mocks/UserProfileMock.swift | 32 +++++++ ElementX/Sources/Other/AvatarSize.swift | 3 + .../StartChat/StartChatCoordinator.swift | 7 +- .../Screens/StartChat/StartChatModels.swift | 31 +++++- .../StartChat/StartChatViewModel.swift | 94 ++++++++++++++++++- .../StartChatViewModelProtocol.swift | 4 + .../StartChat/View/StartChatScreen.swift | 24 +++-- .../View/StartChatSuggestedUserCell.swift | 14 ++- .../Sources/Services/Client/ClientProxy.swift | 23 +++++ .../Services/Client/ClientProxyProtocol.swift | 6 ++ .../Services/Client/MockClientProxy.swift | 8 ++ .../UserSessionFlowCoordinator.swift | 5 +- .../Sources/Services/Users/UserProfile.swift | 36 +++++++ .../Sources/StartChatViewModelTests.swift | 2 +- changelog.d/pr-716.feature | 1 + 17 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 ElementX/Sources/Mocks/UserProfileMock.swift create mode 100644 ElementX/Sources/Services/Users/UserProfile.swift create mode 100644 changelog.d/pr-716.feature diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 933107b8f..3d5bb6535 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -139,3 +139,5 @@ "verification_compare_emojis_title" = "Compare emojis"; "verification_compare_emojis_detail" = "Confirm that the emojis below match those shown on your other session."; "verification_conclusion_ok_self_notice_title" = "Verification complete"; + +"retrieving_direct_room_error" = "An error occurred when trying to start a chat"; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 404b350de..22a8f2b34 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -100,6 +100,8 @@ extension ElementL10n { public static let reportContentInfo = ElementL10n.tr("Untranslated", "report_content_info") /// Report Submitted public static let reportContentSubmitted = ElementL10n.tr("Untranslated", "report_content_submitted") + /// An error occurred when trying to start a chat + public static let retrievingDirectRoomError = ElementL10n.tr("Untranslated", "retrieving_direct_room_error") /// About public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title") /// Copy Link diff --git a/ElementX/Sources/Mocks/UserProfileMock.swift b/ElementX/Sources/Mocks/UserProfileMock.swift new file mode 100644 index 000000000..d06f07971 --- /dev/null +++ b/ElementX/Sources/Mocks/UserProfileMock.swift @@ -0,0 +1,32 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension UserProfileProxy { + // Mocks + static var mockAlice: UserProfileProxy { + .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: URL(staticString: "mxc://matrix.org/UcCimidcvpFvWkPzvjXMQPHA")) + } + + static var mockBob: UserProfileProxy { + .init(userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil) + } + + static var mockCharlie: UserProfileProxy { + .init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil) + } +} diff --git a/ElementX/Sources/Other/AvatarSize.swift b/ElementX/Sources/Other/AvatarSize.swift index 6c551297f..f37243a48 100644 --- a/ElementX/Sources/Other/AvatarSize.swift +++ b/ElementX/Sources/Other/AvatarSize.swift @@ -46,6 +46,7 @@ enum UserAvatarSizeOnScreen { case home case settings case roomDetails + case startChat var value: CGFloat { switch self { @@ -57,6 +58,8 @@ enum UserAvatarSizeOnScreen { return 60 case .roomDetails: return 44 + case .startChat: + return 36 } } } diff --git a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift index 9ab8b2af4..dbb24a892 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift @@ -18,10 +18,12 @@ import SwiftUI struct StartChatCoordinatorParameters { let userSession: UserSessionProtocol + weak var userIndicatorController: UserIndicatorControllerProtocol? } enum StartChatCoordinatorAction { case close + case openRoom(withIdentifier: String) } final class StartChatCoordinator: CoordinatorProtocol { @@ -33,7 +35,7 @@ final class StartChatCoordinator: CoordinatorProtocol { init(parameters: StartChatCoordinatorParameters) { self.parameters = parameters - viewModel = StartChatViewModel(userSession: parameters.userSession) + viewModel = StartChatViewModel(userSession: parameters.userSession, userIndicatorController: parameters.userIndicatorController) } func start() { @@ -43,8 +45,9 @@ final class StartChatCoordinator: CoordinatorProtocol { case .close: self.callback?(.close) case .createRoom: - // TODO: start create room flow break + case .openRoom(let identifier): + self.callback?(.openRoom(withIdentifier: identifier)) } } } diff --git a/ElementX/Sources/Screens/StartChat/StartChatModels.swift b/ElementX/Sources/Screens/StartChat/StartChatModels.swift index e6ef87217..5f27cdd0c 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatModels.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatModels.swift @@ -19,21 +19,48 @@ import Foundation enum StartChatViewModelAction { case close case createRoom + case openRoom(withIdentifier: String) } struct StartChatViewState: BindableState { var bindings = StartChatScreenViewStateBindings() - // TODO: bind with real service, and mock data only in preview - var suggestedUsers: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie] + var isSearching: Bool { + !bindings.searchQuery.isEmpty + } + + var usersSection: StartChatUsersSection = .init(type: .suggestions, users: []) +} + +enum StartChatUserSectionType { + case searchResult + case suggestions + + var title: String? { + switch self { + case .searchResult: + return nil + case .suggestions: + return ElementL10n.directRoomUserListSuggestionsTitle + } + } +} + +struct StartChatUsersSection { + var type: StartChatUserSectionType + var users: [UserProfileProxy] } struct StartChatScreenViewStateBindings { var searchQuery = "" + + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? } enum StartChatViewAction { case close case createRoom case inviteFriends + case selectUser(UserProfileProxy) } diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift index 965aeee06..81a02d620 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import SwiftUI typealias StartChatViewModelType = StateStoreViewModel @@ -22,10 +23,15 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { private let userSession: UserSessionProtocol var callback: ((StartChatViewModelAction) -> Void)? - - init(userSession: UserSessionProtocol) { + weak var userIndicatorController: UserIndicatorControllerProtocol? + + init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) { self.userSession = userSession + self.userIndicatorController = userIndicatorController super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider) + + setupBindings() + fetchSuggestion() } // MARK: - Public @@ -37,8 +43,90 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { case .createRoom: callback?(.createRoom) case .inviteFriends: - // TODO: start invite people flow break + case .selectUser(let user): + showLoadingIndicator() + Task { + let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(user.userID) + switch currentDirectRoom { + case .success(.some(let roomId)): + self.hideLoadingIndicator() + self.callback?(.openRoom(withIdentifier: roomId)) + case .success(nil): + await self.createDirectRoom(with: user) + case .failure(let failure): + self.hideLoadingIndicator() + self.displayError(failure) + } + } } } + + func displayError(_ type: ClientProxyError) { + switch type { + case .failedRetrievingDirectRoom: + state.bindings.alertInfo = AlertInfo(id: type, + title: ElementL10n.dialogTitleError, + message: ElementL10n.retrievingDirectRoomError) + case .failedCreatingRoom: + state.bindings.alertInfo = AlertInfo(id: type, + title: ElementL10n.dialogTitleError, + message: ElementL10n.retrievingDirectRoomError) + default: + state.bindings.alertInfo = AlertInfo(id: type) + } + } + + // MARK: - Private + + private func setupBindings() { + context.$viewState + .map(\.bindings.searchQuery) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .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 = [] + } + } + .store(in: &cancellables) + } + + private func fetchSuggestion() { + state.usersSection.type = .suggestions + state.usersSection.users = [.mockAlice, .mockBob, .mockCharlie] + } + + private func createDirectRoom(with user: UserProfileProxy) async { + showLoadingIndicator() + let result = await userSession.clientProxy.createDirectRoom(with: user.userID) + hideLoadingIndicator() + switch result { + case .success(let roomId): + callback?(.openRoom(withIdentifier: roomId)) + case .failure(let failure): + displayError(failure) + } + } + + // MARK: Loading indicator + + static let loadingIndicatorIdentifier = "StartChatLoading" + + private func showLoadingIndicator() { + userIndicatorController?.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal, + title: ElementL10n.loading, + persistent: true)) + } + + private func hideLoadingIndicator() { + userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift index 7f538d14f..c8ba19c74 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift @@ -20,4 +20,8 @@ import Foundation protocol StartChatViewModelProtocol { var callback: ((StartChatViewModelAction) -> Void)? { get set } var context: StartChatViewModelType.Context { get } + + /// Display an error to the user. + /// - Parameter type: The type of error to be displayed. + func displayError(_ type: ClientProxyError) } diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift index 4d8166029..579a6dd81 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift @@ -21,9 +21,11 @@ struct StartChatScreen: View { var body: some View { Form { - createRoomSection - inviteFriendsSection - suggestionsSection + if !context.viewState.isSearching { + createRoomSection + inviteFriendsSection + } + usersSection } .scrollContentBackground(.hidden) .background(Color.element.formBackground.ignoresSafeArea()) @@ -35,6 +37,7 @@ struct StartChatScreen: View { } } .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: ElementL10n.searchForSomeone) + .alert(item: $context.alertInfo) { $0.alert } } private var createRoomSection: some View { @@ -58,14 +61,19 @@ struct StartChatScreen: View { .formSectionStyle() } - private var suggestionsSection: some View { + private var usersSection: some View { Section { - ForEach(context.viewState.suggestedUsers, id: \.userID) { user in - StartChatSuggestedUserCell(user: user, imageProvider: context.imageProvider) + ForEach(context.viewState.usersSection.users, id: \.userID) { user in + Button { context.send(viewAction: .selectUser(user)) } label: { + StartChatSuggestedUserCell(user: user, imageProvider: context.imageProvider) + } } } header: { - Text(ElementL10n.directRoomUserListSuggestionsTitle) + if let title = context.viewState.usersSection.type.title { + Text(title) + } } + .listRowSeparator(.automatic) .formSectionStyle() } @@ -93,7 +101,7 @@ struct StartChat_Previews: PreviewProvider { static var previews: some View { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), mediaProvider: MockMediaProvider()) - let regularViewModel = StartChatViewModel(userSession: userSession) + let regularViewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) NavigationView { StartChatScreen(context: regularViewModel.context) .tint(.element.accent) diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift index 1997f9ac9..d32a55e46 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift @@ -17,25 +17,23 @@ import SwiftUI struct StartChatSuggestedUserCell: View { - let user: RoomMemberProxyProtocol + let user: UserProfileProxy let imageProvider: ImageProviderProtocol? var body: some View { - HStack(spacing: 13) { + HStack(spacing: 16) { LoadableAvatarImage(url: user.avatarURL, name: user.displayName, contentID: user.userID, - avatarSize: .user(on: .home), + avatarSize: .user(on: .startChat), imageProvider: imageProvider) + .padding(.vertical, 10) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - // covers both nil and empty state - let displayName = user.displayName ?? "" - Text(displayName.isEmpty ? user.userID : displayName) + Text(user.displayName ?? user.userID) .font(.element.title3) .foregroundColor(.element.primaryContent) - if !displayName.isEmpty { + if user.displayName != nil { Text(user.userID) .font(.element.subheadline) .foregroundColor(.element.tertiaryContent) diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 7951c0ae8..b9f6598d1 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -143,6 +143,29 @@ class ClientProxy: ClientProxyProtocol { slidingSyncObserverToken = nil } + func directRoomForUserID(_ userID: String) async -> Result { + await Task.dispatch(on: clientQueue) { + do { + let roomId = try self.client.getDmRoom(userId: userID)?.id() + return .success(roomId) + } catch { + return .failure(.failedRetrievingDirectRoom) + } + } + } + + func createDirectRoom(with userID: String) async -> Result { + await Task.dispatch(on: clientQueue) { + do { + let parameters = CreateRoomParameters(name: "", topic: nil, isEncrypted: true, isDirect: true, visibility: .private, preset: .trustedPrivateChat, invite: [userID], avatar: nil) + let result = try self.client.createRoom(request: parameters) + return .success(result) + } catch { + return .failure(.failedCreatingRoom) + } + } + } + func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { let (slidingSyncRoom, room) = await Task.dispatch(on: clientQueue) { self.roomTupleForIdentifier(identifier) diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index f2aa20292..7f0488e69 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -24,6 +24,8 @@ enum ClientProxyCallback { } enum ClientProxyError: Error { + case failedCreatingRoom + case failedRetrievingDirectRoom case failedRetrievingDisplayName case failedRetrievingAccountData case failedSettingAccountData @@ -70,6 +72,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func stopSync() + func directRoomForUserID(_ userID: String) async -> Result + + func createDirectRoom(with userID: String) async -> Result + func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? func loadUserDisplayName() async -> Result diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 7cf74d926..5c51a9a61 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -43,6 +43,14 @@ class MockClientProxy: ClientProxyProtocol { func stopSync() { } + func directRoomForUserID(_ userID: String) async -> Result { + .failure(.failedRetrievingDirectRoom) + } + + func createDirectRoom(with userID: String) async -> Result { + .failure(.failedCreatingRoom) + } + func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { guard let room = visibleRoomsSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else { return nil diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 3a3d57ea3..9cfab4c17 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -260,13 +260,16 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let userIndicatorController = UserIndicatorController(rootCoordinator: startChatNavigationStackCoordinator) - let parameters = StartChatCoordinatorParameters(userSession: userSession) + let parameters = StartChatCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController) let coordinator = StartChatCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in guard let self else { return } switch action { case .close: self.navigationSplitCoordinator.setSheetCoordinator(nil) + case .openRoom(let identifier): + self.navigationSplitCoordinator.setSheetCoordinator(nil) + self.stateMachine.processEvent(.selectRoom(roomId: identifier)) } } diff --git a/ElementX/Sources/Services/Users/UserProfile.swift b/ElementX/Sources/Services/Users/UserProfile.swift new file mode 100644 index 000000000..6c61f40d0 --- /dev/null +++ b/ElementX/Sources/Services/Users/UserProfile.swift @@ -0,0 +1,36 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +struct UserProfileProxy { + let userID: String + let displayName: String? + let avatarURL: URL? + + init(userID: String, displayName: String?, avatarURL: URL?) { + 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:)) + } +} diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index 160afbaf9..25ffb8b68 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -26,7 +26,7 @@ class StartChatScreenViewModelTests: XCTestCase { @MainActor override func setUpWithError() throws { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""), mediaProvider: MockMediaProvider()) - viewModel = StartChatViewModel(userSession: userSession) + viewModel = StartChatViewModel(userSession: userSession, userIndicatorController: nil) context = viewModel.context } } diff --git a/changelog.d/pr-716.feature b/changelog.d/pr-716.feature new file mode 100644 index 000000000..853b774df --- /dev/null +++ b/changelog.d/pr-716.feature @@ -0,0 +1 @@ +Show or create direct message room \ No newline at end of file