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 <alfogrillo@element.io>

* 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 <alfogrillo@element.io>

* Update ElementX/Sources/Services/Client/ClientProxy.swift

Co-authored-by: Alfonso Grillo <alfogrillo@element.io>

---------

Co-authored-by: Alfonso Grillo <alfogrillo@element.io>
This commit is contained in:
Flescio
2023-03-24 16:16:07 +01:00
committed by GitHub
parent bd2e9e726b
commit d344a20d2e
17 changed files with 269 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ClientProxyError>?
}
enum StartChatViewAction {
case close
case createRoom
case inviteFriends
case selectUser(UserProfileProxy)
}

View File

@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias StartChatViewModelType = StateStoreViewModel<StartChatViewState, StartChatViewAction>
@@ -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)
}
}

View File

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

View File

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

View File

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

View File

@@ -143,6 +143,29 @@ class ClientProxy: ClientProxyProtocol {
slidingSyncObserverToken = nil
}
func directRoomForUserID(_ userID: String) async -> Result<String?, ClientProxyError> {
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<String, ClientProxyError> {
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)

View File

@@ -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<String?, ClientProxyError>
func createDirectRoom(with userID: String) async -> Result<String, ClientProxyError>
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol?
func loadUserDisplayName() async -> Result<String, ClientProxyError>

View File

@@ -43,6 +43,14 @@ class MockClientProxy: ClientProxyProtocol {
func stopSync() { }
func directRoomForUserID(_ userID: String) async -> Result<String?, ClientProxyError> {
.failure(.failedRetrievingDirectRoom)
}
func createDirectRoom(with userID: String) async -> Result<String, ClientProxyError> {
.failure(.failedCreatingRoom)
}
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? {
guard let room = visibleRoomsSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else {
return nil

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Show or create direct message room