mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Invite the DM recipient to a new room (#5588)
* handle the details to invite users flow. * implemented the room creation in the invite screen, and its dismissal + navigaton to the newly created room * updated preview and unit tests * pr suggestions
This commit is contained in:
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
|
||||
struct InviteUsersScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let roomProxy: JoinedRoomProxyProtocol
|
||||
let roomType: InviteUsersScreenRoomType
|
||||
let isSkippable: Bool
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
@@ -20,6 +20,7 @@ struct InviteUsersScreenCoordinatorParameters {
|
||||
|
||||
enum InviteUsersScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case openRoom(roomID: String)
|
||||
}
|
||||
|
||||
final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -33,7 +34,7 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: InviteUsersScreenCoordinatorParameters) {
|
||||
viewModel = InviteUsersScreenViewModel(userSession: parameters.userSession,
|
||||
roomProxy: parameters.roomProxy,
|
||||
roomType: parameters.roomType,
|
||||
isSkippable: parameters.isSkippable,
|
||||
userDiscoveryService: parameters.userDiscoveryService,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
@@ -46,6 +47,8 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .openRoom(let roomID):
|
||||
actionsSubject.send(.openRoom(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -16,11 +16,12 @@ enum InviteUsersScreenErrorType: Error {
|
||||
|
||||
enum InviteUsersScreenViewModelAction {
|
||||
case dismiss
|
||||
case openRoom(roomID: String)
|
||||
}
|
||||
|
||||
enum InviteUsersScreenRoomType {
|
||||
case draft
|
||||
case room(roomProxy: JoinedRoomProxyProtocol)
|
||||
case draft(mandatoryInvitees: [UserProfileProxy])
|
||||
case existingRoom(roomProxy: JoinedRoomProxyProtocol)
|
||||
}
|
||||
|
||||
struct InviteUsersScreenViewState: BindableState {
|
||||
@@ -29,6 +30,7 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
var usersSection: UserDiscoverySection = .init(type: .suggestions, users: [])
|
||||
|
||||
var selectedUsers: [UserProfileProxy] = []
|
||||
var mandatoryInvitees: [UserProfileProxy] = []
|
||||
var membershipState: [String: MembershipState] = .init()
|
||||
var usersToConfirm: [UserProfileProxy] = []
|
||||
|
||||
@@ -38,32 +40,29 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
!isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty
|
||||
}
|
||||
|
||||
var hasInvitableSelectedUsers: Bool {
|
||||
selectedUsers.contains { !isInviteeMandatory($0) }
|
||||
}
|
||||
|
||||
func isUserSelected(_ user: UserProfileProxy) -> Bool {
|
||||
isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID }
|
||||
}
|
||||
|
||||
func isUserDisabled(_ user: UserProfileProxy) -> Bool {
|
||||
if isInviteeMandatory(user) { return true }
|
||||
let membershipState = membershipState(user)
|
||||
return membershipState == .invite || membershipState == .join
|
||||
}
|
||||
|
||||
func isInviteeMandatory(_ user: UserProfileProxy) -> Bool {
|
||||
mandatoryInvitees.contains { $0.userID == user.userID }
|
||||
}
|
||||
|
||||
func membershipState(_ user: UserProfileProxy) -> MembershipState? {
|
||||
membershipState[user.userID]
|
||||
}
|
||||
|
||||
let isSkippable: Bool
|
||||
|
||||
var actionText: String {
|
||||
if isSkippable, selectedUsers.isEmpty {
|
||||
L10n.actionSkip
|
||||
} else {
|
||||
L10n.actionInvite
|
||||
}
|
||||
}
|
||||
|
||||
var isActionDisabled: Bool {
|
||||
isSkippable ? false : selectedUsers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct InviteUsersScreenViewStateBindings {
|
||||
|
||||
@@ -14,7 +14,7 @@ typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreen
|
||||
|
||||
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let roomType: InviteUsersScreenRoomType
|
||||
private let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
@@ -27,21 +27,24 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
roomType: InviteUsersScreenRoomType,
|
||||
isSkippable: Bool,
|
||||
userDiscoveryService: UserDiscoveryServiceProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
clientProxy = userSession.clientProxy
|
||||
self.roomProxy = roomProxy
|
||||
self.roomType = roomType
|
||||
self.userDiscoveryService = userDiscoveryService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appSettings = appSettings
|
||||
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: [],
|
||||
|
||||
let mandatoryInvitees: [UserProfileProxy] = if case .draft(let invitees) = roomType { invitees } else { [] }
|
||||
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: mandatoryInvitees,
|
||||
mandatoryInvitees: mandatoryInvitees,
|
||||
isSkippable: isSkippable),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
|
||||
setupSubscriptions()
|
||||
fetchMembersIfNeeded()
|
||||
|
||||
@@ -61,13 +64,18 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .proceed:
|
||||
guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
|
||||
!state.usersToConfirm.isEmpty,
|
||||
!state.isSkippable else {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
return
|
||||
switch roomType {
|
||||
case .draft:
|
||||
createDraftRoom(mandatoryUserIDs: state.selectedUsers.map(\.userID))
|
||||
case .existingRoom(let roomProxy):
|
||||
guard roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
|
||||
!state.usersToConfirm.isEmpty,
|
||||
!state.isSkippable else {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
return
|
||||
}
|
||||
state.bindings.presentConfirmationDialog = true
|
||||
}
|
||||
state.bindings.presentConfirmationDialog = true
|
||||
case .removeUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.selectedUsers.removeAll { user in
|
||||
@@ -77,7 +85,9 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .confirmUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.usersToConfirm = []
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
if case .existingRoom(let roomProxy) = roomType {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
}
|
||||
case .toggleUser(let user):
|
||||
toggleUser(user)
|
||||
}
|
||||
@@ -86,6 +96,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleUser(_ user: UserProfileProxy) {
|
||||
guard !state.isInviteeMandatory(user) else { return }
|
||||
|
||||
if state.selectedUsers.contains(user) {
|
||||
state.selectedUsers.removeAll { $0.userID == user.userID }
|
||||
} else {
|
||||
@@ -105,6 +117,29 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
}
|
||||
|
||||
private func createDraftRoom(mandatoryUserIDs: [String]) {
|
||||
showLoadingIndicator(title: L10n.commonCreatingRoom)
|
||||
|
||||
Task {
|
||||
defer { hideLoadingIndicator() }
|
||||
|
||||
switch await clientProxy.createRoom(name: nil,
|
||||
topic: nil,
|
||||
accessType: .private,
|
||||
isSpace: false,
|
||||
userIDs: mandatoryUserIDs,
|
||||
avatarURL: nil,
|
||||
aliasLocalPart: nil) {
|
||||
case .success(let roomID):
|
||||
actionsSubject.send(.openRoom(roomID: roomID))
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .unknown,
|
||||
title: L10n.commonError,
|
||||
message: L10n.screenStartChatErrorStartingChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) {
|
||||
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, message: L10n.screenRoomDetailsInvitePeopleDontClose)
|
||||
|
||||
@@ -168,6 +203,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
|
||||
private func fetchMembersIfNeeded() {
|
||||
guard case .existingRoom(let roomProxy) = roomType else { return }
|
||||
|
||||
Task {
|
||||
showLoadingIndicator()
|
||||
await roomProxy.updateMembers()
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ struct InviteUsersConfirmationSheetView_Previews: PreviewProvider, TestablePrevi
|
||||
|
||||
static func makeViewModel() -> InviteUsersScreenViewModel {
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
roomType: .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: UserDiscoveryServiceMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
|
||||
@@ -110,7 +110,9 @@ struct InviteUsersScreen: View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) {
|
||||
InviteUsersScreenSelectedItem(user: user,
|
||||
mediaProvider: context.mediaProvider,
|
||||
isLocked: context.viewState.isInviteeMandatory(user)) {
|
||||
deselect(user)
|
||||
}
|
||||
.frame(width: selectedUserCellWidth)
|
||||
@@ -133,11 +135,18 @@ struct InviteUsersScreen: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(context.viewState.actionText) {
|
||||
context.send(viewAction: .proceed)
|
||||
if context.viewState.isSkippable, context.viewState.selectedUsers.isEmpty {
|
||||
Button(L10n.actionSkip) {
|
||||
context.send(viewAction: .proceed)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
} else {
|
||||
ToolbarButton(role: .confirm(title: L10n.actionInvite)) {
|
||||
context.send(viewAction: .proceed)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
.disabled(!context.viewState.hasInvitableSelectedUsers)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
||||
.disabled(context.viewState.isActionDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +162,7 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let searchingViewModel = makeViewModel(searchQuery: "Alice")
|
||||
static let selectedViewModel = makeViewModel(hasSelection: true)
|
||||
static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true)
|
||||
static let draftViewModel = makeViewModel(roomType: .draft(mandatoryInvitees: [.mockAlice]), isSkippable: false)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
@@ -179,18 +189,28 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
InviteUsersScreen(context: confirmSelectedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Confirm Selected")
|
||||
|
||||
ElementNavigationStack {
|
||||
InviteUsersScreen(context: draftViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Draft (locked invitee)")
|
||||
.snapshotPreferences(expect: draftViewModel.context.$viewState.map { !$0.mandatoryInvitees.isEmpty })
|
||||
}
|
||||
|
||||
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false, shouldConfirm: Bool = false) -> InviteUsersScreenViewModel {
|
||||
static func makeViewModel(searchQuery: String? = nil,
|
||||
hasSelection: Bool = false,
|
||||
shouldConfirm: Bool = false,
|
||||
roomType: InviteUsersScreenRoomType? = nil,
|
||||
isSkippable: Bool = true) -> InviteUsersScreenViewModel {
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
|
||||
|
||||
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
|
||||
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
isSkippable: true,
|
||||
roomType: roomType ?? .existingRoom(roomProxy: JoinedRoomProxyMock(.init(members: []))),
|
||||
isSkippable: isSkippable,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: AppSettings())
|
||||
|
||||
+17
-7
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
struct InviteUsersScreenSelectedItem: View {
|
||||
let user: UserProfileProxy
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
var isLocked = false
|
||||
let dismissAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -25,18 +26,27 @@ struct InviteUsersScreenSelectedItem: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAction(named: L10n.actionRemove, dismissAction)
|
||||
.accessibilityActions {
|
||||
if !isLocked {
|
||||
Button(L10n.actionRemove, action: dismissAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
var avatar: some View {
|
||||
LoadableAvatarImage(url: user.avatarURL,
|
||||
name: user.displayName,
|
||||
contentID: user.userID,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlayRemoveItemButton(action: dismissAction)
|
||||
let avatarImage = LoadableAvatarImage(url: user.avatarURL,
|
||||
name: user.displayName,
|
||||
contentID: user.userID,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
mediaProvider: mediaProvider)
|
||||
if isLocked {
|
||||
avatarImage
|
||||
} else {
|
||||
avatarImage.overlayRemoveItemButton(action: dismissAction)
|
||||
}
|
||||
}
|
||||
|
||||
var closeButtonLabel: some View {
|
||||
|
||||
Reference in New Issue
Block a user