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:
Mauro
2026-05-18 10:08:07 +02:00
committed by GitHub
parent 9ed47c61b7
commit 6c0aaa7a58
39 changed files with 297 additions and 100 deletions
@@ -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()
@@ -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())
@@ -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 {