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
@@ -60,6 +60,13 @@ struct FocusEvent: Hashable {
let shouldSetPin: Bool
}
enum InviteUsersFlow: Hashable {
/// Invite people into the current room.
case existingRoom
/// Start a new room with the given invitee pre-selected and mandatory.
case newRoom(mandatoryInvitee: UserProfileProxy)
}
// swiftlint:disable:next type_body_length
class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let roomID: String
@@ -537,8 +544,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated)
case (_, .presentInviteUsersScreen, .inviteUsersScreen):
presentInviteUsersScreen()
case (_, .presentInviteUsersScreen(let flow), .inviteUsersScreen):
presentInviteUsersScreen(flow: flow)
case (_, .presentTransferOwnershipScreen, .transferOwnershipScreen):
presentTransferOwnershipScreen()
@@ -955,7 +962,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentNotificationSettingsScreen:
stateMachine.tryEvent(.presentNotificationSettingsScreen)
case .presentInviteUsersScreen:
stateMachine.tryEvent(.presentInviteUsersScreen)
stateMachine.tryEvent(.presentInviteUsersScreen(flow: .existingRoom))
case .presentInviteToNewRoom(let mandatoryInvitee):
stateMachine.tryEvent(.presentInviteUsersScreen(flow: .newRoom(mandatoryInvitee: mandatoryInvitee)))
case .presentPollsHistory:
stateMachine.tryEvent(.presentPollsHistory)
case .presentRolesAndPermissionsScreen:
@@ -1326,10 +1335,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentInviteUsersScreen() {
private func presentInviteUsersScreen(flow: InviteUsersFlow = .existingRoom) {
let stackCoordinator = NavigationStackCoordinator()
let roomType: InviteUsersScreenRoomType = switch flow {
case .existingRoom: .existingRoom(roomProxy: roomProxy)
case .newRoom(let mandatoryInvitee): .draft(mandatoryInvitees: [mandatoryInvitee])
}
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: userSession,
roomProxy: roomProxy,
roomType: roomType,
isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController,
@@ -1344,6 +1359,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .openRoom(let roomID):
guard case .newRoom = flow else { return }
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
}
}
.store(in: &cancellables)
@@ -65,7 +65,7 @@ extension RoomFlowCoordinator {
case roomDetailsEditScreen
case notificationSettings
case globalNotificationSettings
case inviteUsersScreen(previousState: State)
case inviteUsersScreen(flow: InviteUsersFlow, previousState: State)
case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State)
case mediaUploadPreview(mediaURLs: [URL], previousState: State)
case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State)
@@ -136,7 +136,7 @@ extension RoomFlowCoordinator {
case presentGlobalNotificationSettingsScreen
case dismissGlobalNotificationSettingsScreen
case presentInviteUsersScreen
case presentInviteUsersScreen(flow: InviteUsersFlow)
case dismissInviteUsersScreen
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
@@ -404,9 +404,9 @@ extension RoomFlowCoordinator {
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState)
case (_, .presentInviteUsersScreen):
return .inviteUsersScreen(previousState: fromState)
case (.inviteUsersScreen(let previousState), .dismissInviteUsersScreen):
case (_, .presentInviteUsersScreen(let flow)):
return .inviteUsersScreen(flow: flow, previousState: fromState)
case (.inviteUsersScreen(_, let previousState), .dismissInviteUsersScreen):
return previousState
case (_, .presentTransferOwnershipScreen):
@@ -261,7 +261,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
private func presentInviteUsersScreen() {
let stackCoordinator = NavigationStackCoordinator()
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy,
roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController,
@@ -276,6 +276,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .openRoom:
break
}
}
.store(in: &cancellables)
@@ -240,7 +240,7 @@ final class SpaceSettingsFlowCoordinator: FlowCoordinatorProtocol {
case .transferOwnership:
stateMachine.tryEvent(.presentTransferOwnership)
case .presentRecipientDetails, .presentNotificationSettingsScreen, .presentReportRoomScreen,
.presentInviteUsersScreen, .presentPollsHistory, .presentCall,
.presentInviteUsersScreen, .presentInviteToNewRoom, .presentPollsHistory, .presentCall,
.presentPinnedEventsTimeline, .presentMediaEventsTimeline, .presentKnockingRequestsListScreen:
fatalError("Not handled in the space context")
}
@@ -270,7 +270,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol, spaceRoomListProxy: SpaceRoomListProxyProtocol?) {
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy,
roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: true,
userDiscoveryService: userDiscoveryService,
userIndicatorController: flowParameters.userIndicatorController,
@@ -285,6 +285,8 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
} else {
actionsSubject.send(.finished(.room(id: roomProxy.id)))
}
case .openRoom:
break
}
}
.store(in: &cancellables)
@@ -3367,8 +3367,8 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled: Bool {
return createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount > 0
}
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments: (name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)?
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedInvocations: [(name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)] = []
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments: (name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)?
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedInvocations: [(name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)] = []
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue: Result<String, ClientProxyError>!
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue: Result<String, ClientProxyError>! {
@@ -3394,9 +3394,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
}
}
}
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure: ((String, String?, CreateRoomAccessType, Bool, [String], URL?, String?) async -> Result<String, ClientProxyError>)?
var createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure: ((String?, String?, CreateRoomAccessType, Bool, [String], URL?, String?) async -> Result<String, ClientProxyError>)?
func createRoom(name: String, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
func createRoom(name: String?, topic: String?, accessType: CreateRoomAccessType, isSpace: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCallsCount += 1
createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments = (name: name, topic: topic, accessType: accessType, isSpace: isSpace, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart)
DispatchQueue.main.async {
@@ -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 {
@@ -26,6 +26,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentRoomDetailsEditScreen
case presentNotificationSettingsScreen
case presentInviteUsersScreen
case presentInviteToNewRoom(invitee: UserProfileProxy)
case presentPollsHistory
case presentRolesAndPermissionsScreen
case presentCall(isVoiceCall: Bool)
@@ -71,6 +72,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomMembersList)
case .requestInvitePeoplePresentation:
actionsSubject.send(.presentInviteUsersScreen)
case .requestInviteToNewRoomPresentation(let invitee):
actionsSubject.send(.presentInviteToNewRoom(invitee: invitee))
case .leftRoom:
actionsSubject.send(.leftRoom)
case .requestEditDetailsPresentation:
@@ -18,6 +18,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
case requestMemberDetailsPresentation
case requestRecipientDetailsPresentation(userID: String)
case requestInvitePeoplePresentation
case requestInviteToNewRoomPresentation(selectedInvitee: UserProfileProxy)
case leftRoom
case requestEditDetailsPresentation
case requestPollsHistoryPresentation
@@ -98,6 +99,7 @@ struct RoomDetailsScreenViewState: BindableState {
}
shortcuts.append(.videoCall)
}
// The invite flow is different for DMs
if dmRecipientInfo == nil, canInviteUsers {
shortcuts.append(.invite)
}
@@ -110,7 +110,11 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
case .processTapPeople:
actionsSubject.send(.requestMemberDetailsPresentation)
case .processTapInvite:
actionsSubject.send(.requestInvitePeoplePresentation)
if let dmRecipient = state.dmRecipientInfo {
actionsSubject.send(.requestInviteToNewRoomPresentation(selectedInvitee: .init(member: dmRecipient.member)))
} else {
actionsSubject.send(.requestInvitePeoplePresentation)
}
case .processTapLeave:
processTapToLeave()
case .confirmLeave:
@@ -22,6 +22,11 @@ struct RoomDetailsScreen: View {
aboutSection
// The invitation flow is different for DMs
if context.viewState.dmRecipientInfo != nil {
inviteToNewRoomSection
}
configurationSection
if context.viewState.dmRecipientInfo == nil {
@@ -165,6 +170,13 @@ struct RoomDetailsScreen: View {
}
}
private var inviteToNewRoomSection: some View {
ListRow(label: .default(title: L10n.actionInvite, icon: \.userAdd),
kind: .navigationLink {
context.send(viewAction: .processTapInvite)
})
}
private var configurationSection: some View {
Section {
ListRow(label: .default(title: L10n.screenRoomDetailsNotificationTitle, icon: \.notifications),
@@ -516,7 +516,7 @@ class ClientProxy: ClientProxyProtocol {
}
}
func createRoom(name: String,
func createRoom(name: String?,
topic: String?,
accessType: CreateRoomAccessType,
isSpace: Bool,
@@ -183,7 +183,7 @@ protocol ClientProxyProtocol: AnyObject {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
func createRoom(name: String,
func createRoom(name: String?,
topic: String?,
accessType: CreateRoomAccessType,
isSpace: Bool,
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9413c3be57bf3882bff847d80d76616413d2c16a63ba316a760548fff2b6386
size 151997
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e70eaef9bc01eccd462be75ac69e1d556784596a0287da9a8f3f7e10e4e58b0
size 156413
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5338f0606064bc4c3c098c2458f1923db59f206c059cfa11bdbc387d42b3af3a
size 99061
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c9456d7cf947a55063eae44ea955c34fd3af41663ffffbe1f72ba47f791cab0
size 102079
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f2efff4b459ba93bc6c8290a3ff46a26a684b2c6abe2812c8a4667a86b060711
size 151210
oid sha256:5a87b4c36df926a3cde0d0a15022e5b5c0c0e545c97974f916b506b4862fc5ab
size 149551
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a6fa5cea94e92da8768cb87d2d157b9552ec3624fbc4f1241c0c212e93f2d98
size 155938
oid sha256:d450d451e9ca7779f4f186f2eabf8a23cef9b2dd861e581ed1c61d0dbbb06499
size 153569
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34bbaf989ac4635edb87a1e2855c45af93eba45aae198a56173186014b79ddb3
size 98426
oid sha256:c793431f951a5ad023ab9b358658a5d75244e292cf56c0fc4a808b84df654e7a
size 97084
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9cebab4fae25f0ea54e0583563719429d06d52949d7847458a98dec6c0ce746d
size 101873
oid sha256:68c222b7a50badb09b9f2cbdcc6010edac0a7a2727ed9f2f98713e2874ffa214
size 100304
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
size 244787
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
size 253574
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
size 181065
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
size 185625
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
size 244787
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
size 253574
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
size 181065
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
size 185625
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c993321bdec5ce94cdd241664bce503fb1c65dc5341355c593f4eeae7f6c222f
size 244787
oid sha256:6bcc46e5933065a1b02dff5c2bf8abc09c626cf6adb6b91acca6c0d27bf8d967
size 236706
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a0c1fd96fe4299e6075b6ebadc330a5943fe5393e3589a93288dc5d87f73ac8
size 253574
oid sha256:f892f94486579fcebfdb3bd5723c233130b10fdabbadac17f687fcedab365085
size 244369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68b3531545e4cecc6b6be0bb009e448cae7bc7ed2cee4686b48a20a780c82bb3
size 181065
oid sha256:ebb51488b9ce71449d0f8ee04bba5f260f924c61186b41c0d6fe8903b06de6b9
size 174363
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:040b3df78b47409a450c6ffec62fb3ff72cbfe74e15f3e85dfbc046a5f2695a7
size 185625
oid sha256:f87dbe6bdd53d79ef618cb86707360df944aeb0ad5916f64a9826eaae400e2b0
size 185369
@@ -87,6 +87,8 @@ final class InviteUsersScreenViewModelTests {
switch action {
case .dismiss:
return true
case .openRoom:
return false
}
}
@@ -122,6 +124,8 @@ final class InviteUsersScreenViewModelTests {
switch action {
case .dismiss:
return true
case .openRoom:
return false
}
}
@@ -159,6 +163,74 @@ final class InviteUsersScreenViewModelTests {
try await deferredState.fulfill()
}
// MARK: - Draft (new room)
@Test
func createsNewRoomInDraftMode() async throws {
userDiscoveryService = UserDiscoveryServiceMock()
userDiscoveryService.searchProfilesWithReturnValue = .success([])
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
let newRoomID = "!newroom:example.com"
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success(newRoomID)
viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomType: .draft(mandatoryInvitees: [.mockAlice]),
isSkippable: false,
userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings())
// The locked invitee starts pre-selected and locked.
#expect(context.viewState.selectedUsers.map(\.userID) == [UserProfileProxy.mockAlice.userID])
#expect(context.viewState.isInviteeMandatory(.mockAlice))
// The proceed button is disabled while the only selected user is the locked invitee.
#expect(!context.viewState.hasInvitableSelectedUsers)
// Selecting a non-locked user enables the proceed button.
var deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
#expect(context.viewState.hasInvitableSelectedUsers)
// Deselecting the non-locked user disables the proceed button again.
deferredState = deferFulfillment(viewModel.context.$viewState) { state in
!state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
#expect(!context.viewState.hasInvitableSelectedUsers)
// Re-select the non-locked user before proceeding.
deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.isUserSelected(.mockBob)
}
context.send(viewAction: .toggleUser(.mockBob))
try await deferredState.fulfill()
let deferredAction = deferFulfillment(viewModel.actions) { action in
if case .openRoom(let roomID) = action, roomID == newRoomID {
return true
}
return false
}
context.send(viewAction: .proceed)
try await deferredAction.fulfill()
let args = try #require(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments)
#expect(args.name == nil)
#expect(args.topic == nil)
#expect(args.accessType == .private)
#expect(args.isSpace == false)
#expect(args.userIDs == [UserProfileProxy.mockAlice.userID, UserProfileProxy.mockBob.userID])
#expect(args.avatarURL == nil)
#expect(args.aliasLocalPart == nil)
}
// MARK: - Helpers
private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) {
@@ -168,7 +240,7 @@ final class InviteUsersScreenViewModelTests {
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomProxy: roomProxy,
roomType: .existingRoom(roomProxy: roomProxy),
isSkippable: isSkippable,
userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock(),