Display members of a space (#4629)

* Present members of a space

* present the members modally from the space

* Implemented a room members flow coordinator to make such flow more modular and reusable

this is required since we will need to reuse this module also in the space settings, and later we could also replace it in the RoomFlowCoordinator.

* the implementation to support at least the SpaceFlowCoordinator is done a follow UP should do the refactor.

* remove modal usage from the flow, we want to always be a navigation flow

* Improved and implemented the room navigation in the members flow coordinator

* pr suggestions and refactored the start chat flow and the invite screen

* updated copies for managing room members

* Update ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro
2025-10-27 13:17:50 +01:00
committed by GitHub
parent 35f8145dde
commit 23194107bb
31 changed files with 561 additions and 217 deletions

View File

@@ -636,6 +636,7 @@
71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; };
71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; };
7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; };
726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; };
72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; };
733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; };
7366E5783D1871D42CF99D34 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; };
@@ -1991,6 +1992,7 @@
5A37E2FACFD041CE466223CD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = "<group>"; };
5A4EF5724C0F894911AF7811 /* SpaceExplorerFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceExplorerFlowCoordinator.swift; sourceTree = "<group>"; };
5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersFlowCoordinator.swift; sourceTree = "<group>"; };
5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = "<group>"; };
5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = "<group>"; };
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = "<group>"; };
@@ -4288,6 +4290,7 @@
A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */,
9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */,
407C8DD85179D2DB896FC0FA /* RoomFlowCoordinatorStateMachine.swift */,
5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */,
0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */,
D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */,
5A4EF5724C0F894911AF7811 /* SpaceExplorerFlowCoordinator.swift */,
@@ -8155,6 +8158,7 @@
6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */,
92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */,
F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */,
726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */,
C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */,
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */,
A975D60EA49F6AF73308809F /* RoomMembersListScreenMemberCell.swift in Sources */,

View File

@@ -495,19 +495,19 @@
"screen_bottom_sheet_create_dm_confirmation_button_title" = "Send invite";
"screen_bottom_sheet_create_dm_message" = "Would you like to start a chat with %1$@?";
"screen_bottom_sheet_create_dm_title" = "Send invite?";
"screen_bottom_sheet_manage_room_member_ban" = "Ban from room";
"screen_bottom_sheet_manage_room_member_ban" = "Ban user";
"screen_bottom_sheet_manage_room_member_ban_member_confirmation_action" = "Ban";
"screen_bottom_sheet_manage_room_member_ban_member_confirmation_description" = "They wont be able to join this room again if invited.";
"screen_bottom_sheet_manage_room_member_ban_member_confirmation_description" = "They wont be able to join again if invited.";
"screen_bottom_sheet_manage_room_member_ban_member_confirmation_title" = "Are you sure you want to ban this member?";
"screen_bottom_sheet_manage_room_member_banning_user" = "Banning %1$@";
"screen_bottom_sheet_manage_room_member_kick_member_confirmation_action" = "Remove";
"screen_bottom_sheet_manage_room_member_kick_member_confirmation_title" = "Are you sure you want to remove this member?";
"screen_bottom_sheet_manage_room_member_member_user_info" = "View profile";
"screen_bottom_sheet_manage_room_member_remove" = "Remove from room";
"screen_bottom_sheet_manage_room_member_remove" = "Remove user";
"screen_bottom_sheet_manage_room_member_remove_confirmation_title" = "Remove member and ban from joining in the future?";
"screen_bottom_sheet_manage_room_member_removing_user" = "Removing %1$@…";
"screen_bottom_sheet_manage_room_member_unban" = "Unban from room";
"screen_bottom_sheet_manage_room_member_unban_member_confirmation_description" = "They would be able to join the room again if invited";
"screen_bottom_sheet_manage_room_member_unban" = "Unban user";
"screen_bottom_sheet_manage_room_member_unban_member_confirmation_description" = "They would be able to join again if invited";
"screen_bottom_sheet_manage_room_member_unban_member_confirmation_title" = "Are you sure you want to unban this member?";
"screen_bug_report_a11y_screenshot" = "Screenshot";
"screen_create_poll_option_accessibility_label" = "%1$@: %2$@";
@@ -1416,7 +1416,7 @@
"screen_room_details_security_title" = "Security";
"screen_room_details_topic_title" = "Topic";
"screen_room_error_failed_processing_media" = "Failed processing media to upload, please try again.";
"screen_room_member_list_manage_member_remove_confirmation_ban" = "Ban from room";
"screen_room_member_list_manage_member_remove_confirmation_ban" = "Ban user";
"screen_room_notification_settings_mode_all_messages" = "All messages";
"screen_room_notification_settings_mode_mentions_and_keywords" = "Mentions and Keywords only";
"screen_room_timeline_reactions_show_less" = "Show less";

View File

@@ -1354,7 +1354,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
selectedUsers: .init(selectedUsersSubject),
roomType: .room(roomProxy: roomProxy),
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController)
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings)
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
stackCoordinator.setRootCoordinator(coordinator)
@@ -1363,22 +1364,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .cancel:
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .proceed:
break
case .invite(let users):
self.inviteUsers(users, in: roomProxy)
case .toggleUser(let user):
var selectedUsers = selectedUsersSubject.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
selectedUsers.remove(at: index)
} else {
selectedUsers.append(user)
}
selectedUsersSubject.send(selectedUsers)
fatalError("Not handled in this flow.")
}
}
.store(in: &cancellables)
@@ -1388,42 +1377,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func inviteUsers(_ users: [String], in room: JoinedRoomProxyProtocol) {
if flowParameters.appSettings.enableKeyShareOnInvite {
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing,
message: L10n.screenRoomDetailsInvitePeopleDontClose)
} else {
showLoadingIndicator()
}
Task {
defer {
navigationStackCoordinator.setSheetCoordinator(nil)
hideLoadingIndicator()
}
let result: Result<Void, RoomProxyError> = await withTaskGroup(of: Result<Void, RoomProxyError>.self) { group in
for user in users {
group.addTask {
await room.invite(userID: user)
}
}
return await group.first { inviteResult in
inviteResult.isFailure
} ?? .success(())
}
guard case .failure = result else {
return
}
flowParameters.userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
}
}
private func presentRolesAndPermissionsScreen() {
let parameters = RoomRolesAndPermissionsFlowCoordinatorParameters(roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,

View File

@@ -0,0 +1,318 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftState
import SwiftUI
enum RoomMembersFlowCoordinatorAction {
case finished
case presentCallScreen(roomProxy: JoinedRoomProxyProtocol)
case verifyUser(userID: String)
}
enum RoomMembersFlowCoordinatorEntryPoint: Equatable {
/// To be used in a room when a member name is tapped
case roomMember(userID: String)
/// To be used in the context of room details, space details etc.
case roomMembersList
}
final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
indirect enum State: StateType {
/// The state machine hasn't started.
case initial
/// The room members list
case roomMembersList
/// The details for a member of the room
case roomMemberDetails(userID: String, previousState: State)
/// In case the details won't load because the user has left the room we load the profile
case userProfile(userID: String, previousState: State)
/// The invite users screen
case inviteUsersScreen
/// A room flow has been started
case roomFlow(roomID: String, previousState: State)
}
enum Event: EventType {
case start
case presentRoomMembersList
case presentRoomMemberDetails(userID: String)
case dismissedRoomMemberDetails
case presentInviteUsersScreen
case dismissedInviteUsersScreen
case presentUserProfile(userID: String)
case dismissedUserProfile
case startRoomFlow(roomID: String)
case stopRoomFlow
}
private let entryPoint: RoomMembersFlowCoordinatorEntryPoint
private let roomProxy: JoinedRoomProxyProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
private let flowParameters: CommonFlowParameters
private let stateMachine: StateMachine<State, Event>
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<RoomMembersFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<RoomMembersFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var roomFlowCoordinator: RoomFlowCoordinator?
init(entryPoint: RoomMembersFlowCoordinatorEntryPoint,
roomProxy: JoinedRoomProxyProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
flowParameters: CommonFlowParameters) {
self.entryPoint = entryPoint
self.roomProxy = roomProxy
self.flowParameters = flowParameters
self.navigationStackCoordinator = navigationStackCoordinator
stateMachine = .init(state: .initial)
configureStateMachine()
}
func start() {
switch entryPoint {
case .roomMember(let userID):
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID))
case .roomMembersList:
stateMachine.tryEvent(.presentRoomMembersList)
}
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError("Unavailable")
}
func clearRoute(animated: Bool) {
if stateMachine.state == .inviteUsersScreen {
navigationStackCoordinator.setSheetCoordinator(nil, animated: animated)
} else if let roomFlowCoordinator {
roomFlowCoordinator.clearRoute(animated: animated)
}
// We don't support dismissing a sub flow by itself, only the entire chain.
// The presenter flow will take care of dismissing it
actionsSubject.send(.finished)
}
private func configureStateMachine() {
stateMachine.addRouteMapping { event, fromState, _ in
switch (fromState, event) {
case (.initial, .presentRoomMembersList):
return .roomMembersList
case (.initial, .presentRoomMemberDetails(let userID)):
// previous state doesn't matter in this csase
return .roomMemberDetails(userID: userID, previousState: fromState)
case (.roomMembersList, .presentRoomMemberDetails(let userID)):
return .roomMemberDetails(userID: userID, previousState: fromState)
case (.roomMemberDetails, .dismissedRoomMemberDetails):
return .roomMembersList
case (.roomMembersList, .presentInviteUsersScreen):
return .inviteUsersScreen
case (.inviteUsersScreen, .dismissedInviteUsersScreen):
return .roomMembersList
case (.roomMemberDetails(_, let previousState), .presentUserProfile(let userID)):
return .userProfile(userID: userID, previousState: previousState)
case (.userProfile(_, let previousState), .dismissedUserProfile):
return previousState
case (_, .startRoomFlow(let roomID)):
return .roomFlow(roomID: roomID, previousState: fromState)
case (.roomFlow(_, let previousState), .stopRoomFlow):
return previousState
default:
return nil
}
}
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
guard let self else { return }
switch (context.fromState, context.event, context.toState) {
case (.initial, .presentRoomMembersList, .roomMembersList):
presentRoomMembersList()
case (.initial, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)):
presentRoomMemberDetails(userID: userID)
case (.roomMembersList, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)):
presentRoomMemberDetails(userID: userID)
case (.roomMemberDetails, .dismissedRoomMemberDetails, .roomMembersList):
break
case (.roomMembersList, .presentInviteUsersScreen, .inviteUsersScreen):
presentInviteUsersScreen()
case (.inviteUsersScreen, .dismissedInviteUsersScreen, .roomMembersList):
break
case (.roomMemberDetails, .presentUserProfile, .userProfile(let userID, _)):
replaceRoomMemberDetailsWithUserProfile(userID: userID)
case (.userProfile, .dismissedUserProfile, _):
break
case (_, .startRoomFlow(let roomID), .roomFlow):
startRoomFlow(roomID: roomID)
case (.roomFlow, .stopRoomFlow, _):
roomFlowCoordinator = nil
default:
fatalError("Unhandled transition")
}
}
}
private func presentRoomMembersList() {
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(userSession: flowParameters.userSession,
roomProxy: roomProxy,
userIndicatorController: flowParameters.userIndicatorController,
analytics: flowParameters.analytics))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .invite:
stateMachine.tryEvent(.presentInviteUsersScreen)
case .selectedMember(let member):
stateMachine.tryEvent(.presentRoomMemberDetails(userID: member.userID))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.actionsSubject.send(.finished)
}
}
private func presentRoomMemberDetails(userID: String) {
let params = RoomMemberDetailsScreenCoordinatorParameters(userID: userID,
roomProxy: roomProxy,
userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController,
analytics: flowParameters.analytics)
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .openUserProfile:
stateMachine.tryEvent(.presentUserProfile(userID: userID))
case .openDirectChat(let roomID):
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
case .startCall(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
guard let self else { return }
if entryPoint == .roomMember(userID: userID) {
actionsSubject.send(.finished)
} else {
stateMachine.tryEvent(.dismissedRoomMemberDetails)
}
}
}
private func presentInviteUsersScreen() {
let stackCoordinator = NavigationStackCoordinator()
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
selectedUsers: nil,
roomType: .room(roomProxy: roomProxy),
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings)
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
stackCoordinator.setRootCoordinator(coordinator)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .proceed:
fatalError("Not handled in this flow.")
}
}
.store(in: &cancellables)
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedInviteUsersScreen)
}
}
private func replaceRoomMemberDetailsWithUserProfile(userID: String) {
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
isPresentedModally: false,
userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController,
analytics: flowParameters.analytics)
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .openDirectChat(let roomID):
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
case .startCall(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .dismiss:
break // Not supported when pushed.
}
}
.store(in: &cancellables)
// Replace the RoomMemberDetailsScreen without any animation.
// If this pop and push happens before the previous navigation is completed it might break screen presentation logic
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
self.navigationStackCoordinator.pop(animated: false)
self.navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in
self?.stateMachine.tryEvent(.dismissedUserProfile)
}
}
}
private func startRoomFlow(roomID: String) {
let coordinator = RoomFlowCoordinator(roomID: roomID,
isChildFlow: true,
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: flowParameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .continueWithSpaceFlow:
fatalError("Will never trigger because only direct chats can be displayed in this flow")
case .finished:
stateMachine.tryEvent(.stopRoomFlow)
}
}
.store(in: &cancellables)
roomFlowCoordinator = coordinator
coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
}
}

View File

@@ -41,6 +41,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
private var childSpaceFlowCoordinator: SpaceFlowCoordinator?
private var roomFlowCoordinator: RoomFlowCoordinator?
private var membersFlowCoordinator: RoomMembersFlowCoordinator?
indirect enum State: StateType {
/// The state machine hasn't started.
@@ -53,6 +54,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case presentingChild(childSpaceID: String, previousState: State)
/// A room flow is in progress
case roomFlow(previousState: State)
/// A members flow is in progress
case membersFlow
case leftSpace
}
@@ -77,6 +80,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case startRoomFlow(roomID: String)
case stopRoomFlow
case startMembersFlow
case stopMembersFlow
}
private let stateMachine: StateMachine<State, Event>
@@ -133,6 +139,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case .roomFlow:
roomFlowCoordinator?.clearRoute(animated: animated)
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
case .membersFlow:
membersFlowCoordinator?.clearRoute(animated: animated)
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
}
}
@@ -193,6 +202,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
selectedSpaceRoomSubject.send(nil)
}
stateMachine.addRouteMapping { event, fromState, _ in
guard case .startMembersFlow = event, case .space = fromState else {
return nil
}
return .membersFlow
} handler: { [weak self] context in
guard let self, let roomProxy = context.userInfo as? JoinedRoomProxyProtocol else {
fatalError("The room proxy must always be provided")
}
Task { await self.startMembersFlow(roomProxy: roomProxy) }
}
stateMachine.addRouteMapping { event, fromState, _ in
guard event == .stopMembersFlow, case .membersFlow = fromState else { return nil }
return .space
} handler: { [weak self] _ in
guard let self else { return }
membersFlowCoordinator = nil
}
stateMachine.addErrorHandler { context in
fatalError("Unexpected transition: \(context)")
}
@@ -219,6 +248,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
case .leftSpace:
stateMachine.tryEvent(.leftSpace)
case .displayMembers(let roomProxy):
stateMachine.tryEvent(.startMembersFlow, userInfo: roomProxy)
}
}
.store(in: &cancellables)
@@ -339,4 +370,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
selectedSpaceRoomSubject.send(roomID)
}
private func startMembersFlow(roomProxy: JoinedRoomProxyProtocol) async {
let flowCoordinator = RoomMembersFlowCoordinator(entryPoint: .roomMembersList,
roomProxy: roomProxy,
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: flowParameters)
flowCoordinator.actions.sink { [weak self] actions in
guard let self else { return }
switch actions {
case .finished:
stateMachine.tryEvent(.stopMembersFlow)
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
}
}
.store(in: &cancellables)
membersFlowCoordinator = flowCoordinator
flowCoordinator.start()
}
}

View File

@@ -1312,11 +1312,11 @@ internal enum L10n {
}
/// Send invite?
internal static var screenBottomSheetCreateDmTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_create_dm_title") }
/// Ban from room
/// Ban user
internal static var screenBottomSheetManageRoomMemberBan: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban") }
/// Ban
internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationAction: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_action") }
/// They wont be able to join this room again if invited.
/// They wont be able to join again if invited.
internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationDescription: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_description") }
/// Are you sure you want to ban this member?
internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_title") }
@@ -1332,7 +1332,7 @@ internal enum L10n {
internal static var screenBottomSheetManageRoomMemberKickMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_kick_member_confirmation_title") }
/// View profile
internal static var screenBottomSheetManageRoomMemberMemberUserInfo: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_member_user_info") }
/// Remove from room
/// Remove user
internal static var screenBottomSheetManageRoomMemberRemove: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_remove") }
/// Remove member and ban from joining in the future?
internal static var screenBottomSheetManageRoomMemberRemoveConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_remove_confirmation_title") }
@@ -1340,11 +1340,11 @@ internal enum L10n {
internal static func screenBottomSheetManageRoomMemberRemovingUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_removing_user", String(describing: p1))
}
/// Unban from room
/// Unban user
internal static var screenBottomSheetManageRoomMemberUnban: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban") }
/// Unban
internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationAction: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_action") }
/// They would be able to join the room again if invited
/// They would be able to join again if invited
internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationDescription: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_description") }
/// Are you sure you want to unban this member?
internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_title") }
@@ -2468,7 +2468,7 @@ internal enum L10n {
internal static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_room_member_list_header_title", p1)
}
/// Ban from room
/// Ban user
internal static var screenRoomMemberListManageMemberRemoveConfirmationBan: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_ban") }
/// Only remove member
internal static var screenRoomMemberListManageMemberRemoveConfirmationKick: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_kick") }

View File

@@ -13,14 +13,14 @@ struct CreateRoomCoordinatorParameters {
let userSession: UserSessionProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let createRoomParameters: CurrentValuePublisher<CreateRoomFlowParameters, Never>
let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>
let selectedUsers: [UserProfileProxy]
let appSettings: AppSettings
let analytics: AnalyticsService
}
enum CreateRoomCoordinatorAction {
case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy)
case updateSelectedUsers([UserProfileProxy])
case updateDetails(CreateRoomFlowParameters)
case displayMediaPickerWithMode(MediaPickerScreenMode)
case removeImage
@@ -48,8 +48,8 @@ final class CreateRoomCoordinator: CoordinatorProtocol {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .deselectUser(let user):
actionsSubject.send(.deselectUser(user))
case .updateSelectedUsers(let users):
actionsSubject.send(.updateSelectedUsers(users))
case .openRoom(let identifier):
actionsSubject.send(.openRoom(withIdentifier: identifier))
case .updateDetails(let details):

View File

@@ -18,7 +18,7 @@ enum CreateRoomScreenErrorType: Error {
enum CreateRoomViewModelAction {
case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy)
case updateSelectedUsers([UserProfileProxy])
case updateDetails(CreateRoomFlowParameters)
case displayMediaPicker
case displayCameraPicker

View File

@@ -28,7 +28,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
init(userSession: UserSessionProtocol,
createRoomParameters: CurrentValuePublisher<CreateRoomFlowParameters, Never>,
selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>,
selectedUsers: [UserProfileProxy],
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings) {
@@ -46,7 +46,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
super.init(initialViewState: CreateRoomViewState(roomName: parameters.name,
serverName: userSession.clientProxy.userIDServerName ?? "",
isKnockingFeatureEnabled: appSettings.knockingEnabled,
selectedUsers: selectedUsers.value,
selectedUsers: selectedUsers,
aliasLocalPart: parameters.aliasLocalPart ?? roomAliasNameFromRoomDisplayName(roomName: parameters.name),
bindings: bindings),
mediaProvider: userSession.mediaProvider)
@@ -67,12 +67,6 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
}
.store(in: &cancellables)
selectedUsers
.sink { [weak self] users in
self?.state.selectedUsers = users
}
.store(in: &cancellables)
setupBindings()
}
@@ -85,7 +79,8 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
await createRoom()
}
case .deselectUser(let user):
actionsSubject.send(.deselectUser(user))
state.selectedUsers.removeAll { $0.userID == user.userID }
actionsSubject.send(.updateSelectedUsers(state.selectedUsers))
case .displayCameraPicker:
actionsSubject.send(.displayCameraPicker)
case .displayMediaPicker:

View File

@@ -11,17 +11,16 @@ import SwiftUI
struct InviteUsersScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>
let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?
let roomType: InviteUsersScreenRoomType
let userDiscoveryService: UserDiscoveryServiceProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
}
enum InviteUsersScreenCoordinatorAction {
case cancel
case proceed
case invite(users: [String])
case toggleUser(UserProfileProxy)
case dismiss
case proceed(selectedUsers: [UserProfileProxy])
}
final class InviteUsersScreenCoordinator: CoordinatorProtocol {
@@ -38,21 +37,18 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
selectedUsers: parameters.selectedUsers,
roomType: parameters.roomType,
userDiscoveryService: parameters.userDiscoveryService,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings)
}
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
actionsSubject.send(.cancel)
case .proceed:
actionsSubject.send(.proceed)
case .invite(let users):
actionsSubject.send(.invite(users: users))
case .toggleUser(let user):
actionsSubject.send(.toggleUser(user))
case .dismiss:
actionsSubject.send(.dismiss)
case .proceed(let selectedUsers):
actionsSubject.send(.proceed(selectedUsers: selectedUsers))
}
}
.store(in: &cancellables)

View File

@@ -15,10 +15,8 @@ enum InviteUsersScreenErrorType: Error {
}
enum InviteUsersScreenViewModelAction {
case cancel
case proceed
case invite(users: [String])
case toggleUser(UserProfileProxy)
case dismiss
case proceed(selectedUsers: [UserProfileProxy])
}
enum InviteUsersScreenRoomType {

View File

@@ -16,6 +16,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
private let roomType: InviteUsersScreenRoomType
private let userDiscoveryService: UserDiscoveryServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private var suggestedUsers = [UserProfileProxy]()
@@ -24,20 +25,25 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
actionsSubject.eraseToAnyPublisher()
}
private let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?
init(userSession: UserSessionProtocol,
selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>,
selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?,
roomType: InviteUsersScreenRoomType,
userDiscoveryService: UserDiscoveryServiceProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings) {
self.roomType = roomType
self.userDiscoveryService = userDiscoveryService
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
self.selectedUsers = selectedUsers
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value,
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers?.value ?? [],
isCreatingRoom: roomType.isCreatingRoom),
mediaProvider: userSession.mediaProvider)
setupSubscriptions(selectedUsers: selectedUsers)
setupSubscriptions()
fetchMembersIfNeeded()
Task {
@@ -54,23 +60,67 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
override func process(viewAction: InviteUsersScreenViewAction) {
switch viewAction {
case .cancel:
actionsSubject.send(.cancel)
actionsSubject.send(.dismiss)
case .proceed:
switch roomType {
case .draft:
actionsSubject.send(.proceed)
case .room:
actionsSubject.send(.invite(users: state.selectedUsers.map(\.userID)))
actionsSubject.send(.proceed(selectedUsers: state.selectedUsers))
case .room(let roomProxy):
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
}
case .toggleUser(let user):
let willSelectUser = !state.selectedUsers.contains(user)
state.scrollToLastID = willSelectUser ? user.userID : nil
actionsSubject.send(.toggleUser(user))
toggleUser(user)
}
}
// MARK: - Private
private func toggleUser(_ user: UserProfileProxy) {
if state.selectedUsers.contains(user) {
state.scrollToLastID = nil
state.selectedUsers.removeAll(where: { $0.userID == user.userID })
} else {
state.scrollToLastID = user.userID
state.selectedUsers.append(user)
}
}
private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) {
if appSettings.enableKeyShareOnInvite {
showLoader(title: L10n.screenRoomDetailsInvitePeoplePreparing,
message: L10n.screenRoomDetailsInvitePeopleDontClose)
} else {
showLoader()
}
Task {
defer {
hideLoader()
actionsSubject.send(.dismiss)
}
let result: Result<Void, RoomProxyError> = await withTaskGroup(of: Result<Void, RoomProxyError>.self) { group in
for user in users {
group.addTask {
await roomProxy.invite(userID: user)
}
}
return await group.first { inviteResult in
inviteResult.isFailure
} ?? .success(())
}
guard case .failure = result else {
return
}
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
}
}
private func buildMembershipStateIfNeeded(members: [RoomMemberProxyProtocol]) {
showLoader()
@@ -92,7 +142,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
@CancellableTask
private var fetchUsersTask: Task<Void, Never>?
private func setupSubscriptions(selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>) {
private func setupSubscriptions() {
context.$viewState
.map(\.bindings.searchQuery)
.debounceTextQueriesAndRemoveDuplicates()
@@ -101,11 +151,13 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
}
.store(in: &cancellables)
selectedUsers
.sink { [weak self] users in
self?.state.selectedUsers = users
}
.store(in: &cancellables)
if let selectedUsers {
selectedUsers
.sink { [weak self] users in
self?.state.selectedUsers = users
}
.store(in: &cancellables)
}
}
private func fetchMembersIfNeeded() {
@@ -159,8 +211,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
private let userIndicatorID = UUID().uuidString
private func showLoader() {
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.commonLoading, persistent: true), delay: .milliseconds(200))
private func showLoader(title: String = L10n.commonLoading,
message: String? = nil) {
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID,
type: .modal,
title: title,
message: message,
persistent: true),
delay: .milliseconds(200))
}
private func hideLoader() {

View File

@@ -158,7 +158,8 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
selectedUsers: .init([]),
roomType: .draft,
userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock())
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
}()
static var previews: some View {

View File

@@ -48,6 +48,7 @@ struct RoomMembersListScreen: View {
.autocorrectionDisabled()
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationTitle(L10n.commonPeople)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $context.manageMemeberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}

View File

@@ -24,6 +24,7 @@ enum SpaceScreenCoordinatorAction {
case selectUnjoinedSpace(SpaceRoomProxyProtocol)
case selectRoom(roomID: String)
case leftSpace
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
@@ -61,6 +62,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.selectRoom(roomID: roomID))
case .leftSpace:
actionsSubject.send(.leftSpace)
case .displayMembers(let roomProxy):
actionsSubject.send(.displayMembers(roomProxy: roomProxy))
}
}
.store(in: &cancellables)

View File

@@ -13,12 +13,14 @@ enum SpaceScreenViewModelAction {
case selectUnjoinedSpace(SpaceRoomProxyProtocol)
case selectRoom(roomID: String)
case leftSpace
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
}
struct SpaceScreenViewState: BindableState {
var space: SpaceRoomProxyProtocol
var permalink: URL?
var roomProxy: JoinedRoomProxyProtocol?
var isPaginating = false
var rooms: [SpaceRoomProxyProtocol]
@@ -42,4 +44,5 @@ enum SpaceScreenViewAction {
case toggleLeaveSpaceRoomDetails(id: String)
case confirmLeaveSpace
case spaceSettings
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
}

View File

@@ -68,9 +68,13 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
.store(in: &cancellables)
Task {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id),
case let .success(permalinkURL) = await roomProxy.matrixToPermalink() {
state.permalink = permalinkURL
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id) {
// Required to listen for membership updates in the members flow
await roomProxy.subscribeForUpdates()
state.roomProxy = roomProxy
if case let .success(permalinkURL) = await roomProxy.matrixToPermalink() {
state.permalink = permalinkURL
}
}
}
}
@@ -115,6 +119,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
case .confirmLeaveSpace:
Task { await confirmLeaveSpace() }
case .displayMembers(let roomProxy):
actionsSubject.send(.displayMembers(roomProxy: roomProxy))
case .spaceSettings:
break // Not implemented.
}

View File

@@ -62,8 +62,13 @@ struct SpaceScreen: View {
// controller attempts to anchor itself to the button that is no longer visible.
ToolbarItem(placement: .primaryAction) {
Menu {
if let permalink = context.viewState.permalink {
Section {
Section {
if let roomProxy = context.viewState.roomProxy {
Button { context.send(viewAction: .displayMembers(roomProxy: roomProxy)) } label: {
Label(L10n.screenSpaceMenuActionMembers, icon: \.user)
}
}
if let permalink = context.viewState.permalink {
ShareLink(item: permalink) {
Label(L10n.actionShare, icon: \.shareIos)
}
@@ -106,10 +111,16 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy,
initialSpaceRooms: .mockSpaceList))
let clientProxy = ClientProxyMock(.init())
clientProxy.roomForIdentifierClosure = { _ in
.joined(JoinedRoomProxyMock(.init()))
}
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
spaceServiceProxy: SpaceServiceProxyMock(.init()),
selectedSpaceRoomPublisher: .init(nil),
userSession: UserSessionMock(.init()),
userSession: userSession,
userIndicatorController: UserIndicatorControllerMock())
return viewModel
}

View File

@@ -87,20 +87,17 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
selectedUsers: selectedUsersPublisher,
roomType: .draft,
userDiscoveryService: parameters.userDiscoveryService,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings)
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
break // Not shown in this flow.
case .proceed:
case .dismiss:
fatalError("Not shown in this flow.")
case .proceed(let selectedUsers):
self.selectedUsers.send(selectedUsers)
openCreateRoomScreen()
case .invite:
break
case .toggleUser(let user):
toggleUser(user)
}
}
.store(in: &cancellables)
@@ -115,15 +112,15 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession,
userIndicatorController: parameters.userIndicatorController,
createRoomParameters: createRoomParametersPublisher,
selectedUsers: selectedUsersPublisher,
selectedUsers: selectedUsers.value,
appSettings: parameters.appSettings,
analytics: parameters.analytics)
let coordinator = CreateRoomCoordinator(parameters: createParameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .deselectUser(let user):
self.toggleUser(user)
case .updateSelectedUsers(let users):
self.selectedUsers.send(users)
case .updateDetails(let details):
self.createRoomParameters.send(details)
case .openRoom(let identifier):
@@ -190,16 +187,6 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
}
}
private func toggleUser(_ user: UserProfileProxy) {
var selectedUsers = selectedUsers.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
selectedUsers.remove(at: index)
} else {
selectedUsers.append(user)
}
self.selectedUsers.send(selectedUsers)
}
// MARK: Loading indicator
private static let loadingIndicatorIdentifier = "\(StartChatScreenCoordinator.self)-Loading"

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8d2b28b530b24af558f8fae27de0d170e3b1e0578120e33e83970d9d49723e34
size 196380
oid sha256:09ed4bd158ac78c3bc2c79b002123c8acd01fa2c916117c4b2f25c546c2877dd
size 196319

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d6f04d43984ace12233b6b3362af3bb77d4942ea1297e0050b8b5955c25130ef
size 137585
oid sha256:c93361807cc6a52d22c29e9795a0d2b9ca39b6538f82e99d47d5ec205ff604c9
size 136562

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a95d9aade23b43dc3941fd270aa56b40b02b9e9ab08f6944ebed09870f13b120
size 196824
oid sha256:65ed07a3fede8980ff18ad8948b54a9d45f2cae7ea3889f070e0bcb5212aff6c
size 196864

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68236a129b6528f9a9a688ce70205cf15afbcfe5c923b604b7ec1678c4a846a5
size 138159
oid sha256:63763276f73d6f8a0e632853f0a746b65e0bca5438d62d34da5bcee8f22de111
size 137171

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9d357a82eea7676227d54dc28e6b9ec84845c07c69ce9e0e24810e8a8a46335
size 190488
oid sha256:8691d45d5629c189e6a97421aad1ff73f89bbe5373271cae18f2c0321a4fd3cc
size 190379

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f67d0bc3d1407fe230c418d4d3fe492af4e8a4a7057d07124f8d259801328ae
size 131411
oid sha256:3398bc4647612b235ce3bfeb18941044fd985cefc239379b51f3003a57bc6ba4
size 130984

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:552d0fddd4944914a7aeb2678cdb991d5f55599f8f09938ca10cc23f37de8967
size 189773
oid sha256:0661718686f07bd5a4c36f7211064030f7d0f76193ef489f9927dd83fd9703d1
size 189924

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:684526b282a46532d45dc048c806b1c5b81ed06fa3ec12496d5f0c2036c53192
size 131331
oid sha256:86e26201a4c716cb1b2b229b3f89805297828f52feb29fa8cff22a9c29c8eda8
size 130816

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bba02f0b1a660e3a7b813fb36bf66afe00d3adee4c287651531ac5cf95863b9a
size 105278
oid sha256:072af060db2d6ab8464cd15e064dc792bd781d45b08b7ef6cc70115e5f2ecfa0
size 104992

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7bd4668b039105fc66b51759be8f12581dbfba3d8012dc595e6a342dbc366b60
size 56240
oid sha256:a88d8981907ce176f1af65d81804b27366b04feec2c9ea185d335aa6f5c5858f
size 55886

View File

@@ -18,41 +18,23 @@ class CreateRoomScreenViewModelTests: XCTestCase {
var userSession: UserSessionMock!
private let usersSubject = CurrentValueSubject<[UserProfileProxy], Never>([])
private var cancellables = Set<AnyCancellable>()
var context: CreateRoomViewModel.Context {
viewModel.context
}
override func setUpWithError() throws {
cancellables.removeAll()
clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org", userID: "@a:b.com"))
userSession = UserSessionMock(.init(clientProxy: clientProxy))
let parameters = CreateRoomFlowParameters()
usersSubject.send([.mockAlice, .mockBob, .mockCharlie])
ServiceLocator.shared.settings.knockingEnabled = true
let viewModel = CreateRoomViewModel(userSession: userSession,
createRoomParameters: .init(parameters),
selectedUsers: usersSubject.asCurrentValuePublisher(),
selectedUsers: [.mockAlice, .mockBob, .mockCharlie],
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
self.viewModel = viewModel
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .deselectUser(let user):
var selectedUsers = usersSubject.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
selectedUsers.remove(at: index)
}
usersSubject.send(selectedUsers)
default:
break
}
}
.store(in: &cancellables)
}
func testDeselectUser() {

View File

@@ -15,17 +15,11 @@ import XCTest
class InviteUsersScreenViewModelTests: XCTestCase {
var viewModel: InviteUsersScreenViewModelProtocol!
var userDiscoveryService: UserDiscoveryServiceMock!
private var cancellables = Set<AnyCancellable>()
var context: InviteUsersScreenViewModel.Context {
viewModel.context
}
override func setUp() {
cancellables.removeAll()
}
func testSelectUser() {
setupWithRoomType(roomType: .draft)
XCTAssertTrue(context.viewState.selectedUsers.isEmpty)
@@ -56,7 +50,9 @@ class InviteUsersScreenViewModelTests: XCTestCase {
func testInviteButton() async throws {
let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob]
setupWithRoomType(roomType: .room(roomProxy: JoinedRoomProxyMock(.init(name: "test", members: mockedMembers))))
let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers))
roomProxy.inviteUserIDReturnValue = .success(())
setupWithRoomType(roomType: .room(roomProxy: roomProxy))
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.isUserSelected(.mockAlice)
@@ -68,7 +64,7 @@ class InviteUsersScreenViewModelTests: XCTestCase {
let deferredAction = deferFulfillment(viewModel.actions) { action in
switch action {
case .invite:
case .dismiss:
return true
default:
return false
@@ -77,41 +73,20 @@ class InviteUsersScreenViewModelTests: XCTestCase {
context.send(viewAction: .proceed)
guard case let .invite(members) = try await deferredAction.fulfill() else {
XCTFail("Sent action should be 'invite'")
return
}
XCTAssertEqual(members, [RoomMemberProxyMock.mockAlice.userID])
try await deferredAction.fulfill()
XCTAssertEqual(roomProxy.inviteUserIDReceivedInvocations, [RoomMemberProxyMock.mockAlice.userID])
}
private func setupWithRoomType(roomType: InviteUsersScreenRoomType) {
let usersSubject = CurrentValueSubject<[UserProfileProxy], Never>([])
userDiscoveryService = UserDiscoveryServiceMock()
userDiscoveryService.searchProfilesWithReturnValue = .success([])
usersSubject.send([])
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init()),
selectedUsers: usersSubject.asCurrentValuePublisher(),
selectedUsers: nil,
roomType: roomType,
userDiscoveryService: userDiscoveryService,
userIndicatorController: UserIndicatorControllerMock())
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie])
self.viewModel = viewModel
viewModel.actions.sink { action in
switch action {
case .toggleUser(let user):
var selectedUsers = usersSubject.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
selectedUsers.remove(at: index)
} else {
selectedUsers.append(user)
}
usersSubject.send(selectedUsers)
default:
break
}
}
.store(in: &cancellables)
}
}