mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user