mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Invite people in an existing room (#915)
* Handle power levels * Add “invite” button in RoomMembersListScreen * Refactor DI in RoomDetailsScreenCoordinator * Add navigation to “select people” coordinator * Add InviteUsersScreenRoomContext * Add invite user by id api * Updare InviteUsersScreenRoomContext * Support disabled state on FormButtonStyles * Handle membership in UserProfileCell * Fix conflicts * Refactor RoomContext -> RoomType * Refactor UserProfileCell * Add invite action * Setup navigation to user flow coordinator * Add invite logic * Fix test build errors * Address comments * Fix localisations * Update required state * Handle error * Move invite logic in RoomDetailsScreenCoordinator * Add navigation from room’s members list * Fix UTs * Fix missing display name in RoomMemberDetails * Add user indicator * Add feature flag * Add RoomDetailsViewModel UTs * Add room details ui tests * Add InviteUsersViewModelTests UTs * Add UI tests * Update UI tests * Empty commit
This commit is contained in:
@@ -29,6 +29,7 @@ struct DeveloperOptionsScreenViewStateBindings {
|
||||
var startChatFlowEnabled: Bool
|
||||
var startChatUserSuggestionsEnabled: Bool
|
||||
var invitesFlowEnabled: Bool
|
||||
var inviteMorePeopleFlowEnabled: Bool
|
||||
}
|
||||
|
||||
enum DeveloperOptionsScreenViewAction {
|
||||
@@ -36,5 +37,6 @@ enum DeveloperOptionsScreenViewAction {
|
||||
case changedStartChatFlowEnabled
|
||||
case changedStartChatUserSuggestionsEnabled
|
||||
case changedInvitesFlowEnabled
|
||||
case changedInviteMorePeopleFlowEnabled
|
||||
case clearCache
|
||||
}
|
||||
|
||||
+15
-9
@@ -21,16 +21,20 @@ typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel<DeveloperOpt
|
||||
class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, DeveloperOptionsScreenViewModelProtocol {
|
||||
var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)?
|
||||
|
||||
private let appSettings: AppSettings
|
||||
|
||||
init() {
|
||||
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents,
|
||||
startChatFlowEnabled: ServiceLocator.shared.settings.startChatFlowEnabled,
|
||||
startChatUserSuggestionsEnabled: ServiceLocator.shared.settings.startChatUserSuggestionsEnabled,
|
||||
invitesFlowEnabled: ServiceLocator.shared.settings.invitesFlowEnabled)
|
||||
appSettings = ServiceLocator.shared.settings
|
||||
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: appSettings.shouldCollapseRoomStateEvents,
|
||||
startChatFlowEnabled: appSettings.startChatFlowEnabled,
|
||||
startChatUserSuggestionsEnabled: appSettings.startChatUserSuggestionsEnabled,
|
||||
invitesFlowEnabled: appSettings.invitesFlowEnabled,
|
||||
inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled)
|
||||
let state = DeveloperOptionsScreenViewState(bindings: bindings)
|
||||
|
||||
super.init(initialViewState: state)
|
||||
|
||||
ServiceLocator.shared.settings.$shouldCollapseRoomStateEvents
|
||||
appSettings.$shouldCollapseRoomStateEvents
|
||||
.weakAssign(to: \.state.bindings.shouldCollapseRoomStateEvents, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
@@ -38,13 +42,15 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
|
||||
override func process(viewAction: DeveloperOptionsScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .changedShouldCollapseRoomStateEvents:
|
||||
ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
|
||||
appSettings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
|
||||
case .changedStartChatFlowEnabled:
|
||||
ServiceLocator.shared.settings.startChatFlowEnabled = state.bindings.startChatFlowEnabled
|
||||
appSettings.startChatFlowEnabled = state.bindings.startChatFlowEnabled
|
||||
case .changedStartChatUserSuggestionsEnabled:
|
||||
ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled
|
||||
appSettings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled
|
||||
case .changedInvitesFlowEnabled:
|
||||
ServiceLocator.shared.settings.invitesFlowEnabled = state.bindings.invitesFlowEnabled
|
||||
appSettings.invitesFlowEnabled = state.bindings.invitesFlowEnabled
|
||||
case .changedInviteMorePeopleFlowEnabled:
|
||||
appSettings.inviteMorePeopleFlowEnabled = state.bindings.inviteMorePeopleFlowEnabled
|
||||
case .clearCache:
|
||||
callback?(.clearCache)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.startChatFlowEnabled) {
|
||||
Text("Show Start Chat flow")
|
||||
Text("Show start chat flow")
|
||||
}
|
||||
.onChange(of: context.startChatFlowEnabled) { _ in
|
||||
context.send(viewAction: .changedStartChatFlowEnabled)
|
||||
@@ -45,11 +45,18 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.invitesFlowEnabled) {
|
||||
Text("Show Invites flow")
|
||||
Text("Show invites flow")
|
||||
}
|
||||
.onChange(of: context.invitesFlowEnabled) { _ in
|
||||
context.send(viewAction: .changedInvitesFlowEnabled)
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.inviteMorePeopleFlowEnabled) {
|
||||
Text("Show invite more people flow")
|
||||
}
|
||||
.onChange(of: context.inviteMorePeopleFlowEnabled) { _ in
|
||||
context.send(viewAction: .changedInviteMorePeopleFlowEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
|
||||
@@ -337,7 +337,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
defer {
|
||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true))
|
||||
|
||||
let room = await userSession.clientProxy.roomForIdentifier(roomId)
|
||||
let result = await room?.leaveRoom()
|
||||
|
||||
@@ -18,14 +18,15 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct InviteUsersScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
let selectedUsers: CurrentValuePublisher<[UserProfile], Never>
|
||||
let roomType: InviteUsersScreenRoomType
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
}
|
||||
|
||||
enum InviteUsersScreenCoordinatorAction {
|
||||
case close
|
||||
case proceed
|
||||
case invite(users: [String])
|
||||
case toggleUser(UserProfile)
|
||||
}
|
||||
|
||||
@@ -42,17 +43,20 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
init(parameters: InviteUsersScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = InviteUsersScreenViewModel(selectedUsers: parameters.selectedUsers, userSession: parameters.userSession, userDiscoveryService: parameters.userDiscoveryService)
|
||||
viewModel = InviteUsersScreenViewModel(selectedUsers: parameters.selectedUsers,
|
||||
roomType: parameters.roomType,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userDiscoveryService: parameters.userDiscoveryService)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .close:
|
||||
self.actionsSubject.send(.close)
|
||||
case .proceed:
|
||||
self.actionsSubject.send(.proceed)
|
||||
case .invite(let users):
|
||||
self.actionsSubject.send(.invite(users: users))
|
||||
case .toggleUser(let user):
|
||||
self.actionsSubject.send(.toggleUser(user))
|
||||
}
|
||||
|
||||
@@ -15,23 +15,30 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum InviteUsersScreenErrorType: Error {
|
||||
case unknown
|
||||
}
|
||||
|
||||
enum InviteUsersScreenViewModelAction {
|
||||
case close
|
||||
case proceed
|
||||
case invite(users: [String])
|
||||
case toggleUser(UserProfile)
|
||||
}
|
||||
|
||||
enum InviteUsersScreenRoomType {
|
||||
case draft
|
||||
case room(members: [RoomMemberProxyProtocol], userIndicatorController: UserIndicatorControllerProtocol)
|
||||
}
|
||||
|
||||
struct InviteUsersScreenViewState: BindableState {
|
||||
var bindings = InviteUsersScreenViewStateBindings()
|
||||
|
||||
var usersSection: UserDiscoverySection = .init(type: .suggestions, users: [])
|
||||
|
||||
var selectedUsers: [UserProfile] = []
|
||||
var membershipState: [String: MembershipState] = .init()
|
||||
|
||||
var isSearching: Bool {
|
||||
!bindings.searchQuery.isEmpty
|
||||
@@ -44,7 +51,30 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
var scrollToLastID: String?
|
||||
|
||||
func isUserSelected(_ user: UserProfile) -> Bool {
|
||||
selectedUsers.contains { $0.userID == user.userID }
|
||||
isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID }
|
||||
}
|
||||
|
||||
func isUserDisabled(_ user: UserProfile) -> Bool {
|
||||
let membershipState = membershipState(user)
|
||||
return membershipState == .invite || membershipState == .join
|
||||
}
|
||||
|
||||
func membershipState(_ user: UserProfile) -> MembershipState? {
|
||||
membershipState[user.userID]
|
||||
}
|
||||
|
||||
let isCreatingRoom: Bool
|
||||
|
||||
var actionText: String {
|
||||
if isCreatingRoom {
|
||||
return selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext
|
||||
} else {
|
||||
return L10n.actionInvite
|
||||
}
|
||||
}
|
||||
|
||||
var isActionDisabled: Bool {
|
||||
isCreatingRoom ? false : selectedUsers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +86,6 @@ struct InviteUsersScreenViewStateBindings {
|
||||
}
|
||||
|
||||
enum InviteUsersScreenViewAction {
|
||||
case close
|
||||
case proceed
|
||||
case toggleUser(UserProfile)
|
||||
}
|
||||
|
||||
@@ -15,49 +15,77 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreenViewState, InviteUsersScreenViewAction>
|
||||
|
||||
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
|
||||
private let userSession: UserSessionProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
private let roomType: InviteUsersScreenRoomType
|
||||
private let actionsSubject: PassthroughSubject<InviteUsersScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<InviteUsersScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(selectedUsers: CurrentValuePublisher<[UserProfile], Never>, userSession: UserSessionProtocol, userDiscoveryService: UserDiscoveryServiceProtocol) {
|
||||
self.userSession = userSession
|
||||
init(selectedUsers: CurrentValuePublisher<[UserProfile], Never>,
|
||||
roomType: InviteUsersScreenRoomType,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
userDiscoveryService: UserDiscoveryServiceProtocol) {
|
||||
self.roomType = roomType
|
||||
self.mediaProvider = mediaProvider
|
||||
self.userDiscoveryService = userDiscoveryService
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value), imageProvider: userSession.mediaProvider)
|
||||
|
||||
selectedUsers
|
||||
.sink { [weak self] users in
|
||||
self?.state.selectedUsers = users
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupSubscriptions()
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value, isCreatingRoom: roomType.isCreatingRoom), imageProvider: mediaProvider)
|
||||
|
||||
buildMembershipStateIfNeeded()
|
||||
setupSubscriptions(selectedUsers: selectedUsers)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: InviteUsersScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .close:
|
||||
actionsSubject.send(.close)
|
||||
case .proceed:
|
||||
actionsSubject.send(.proceed)
|
||||
switch roomType {
|
||||
case .draft:
|
||||
actionsSubject.send(.proceed)
|
||||
case .room:
|
||||
actionsSubject.send(.invite(users: state.selectedUsers.map(\.userID)))
|
||||
}
|
||||
case .toggleUser(let user):
|
||||
actionsSubject.send(.toggleUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
private func buildMembershipStateIfNeeded() {
|
||||
guard case let .room(members, userIndicatorController) = roomType else {
|
||||
return
|
||||
}
|
||||
let indicatorID = UUID().uuidString
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
|
||||
Task.detached { [members] in
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
let membershipState = members
|
||||
.reduce(into: [String: MembershipState]()) { partialResult, member in
|
||||
partialResult[member.userID] = member.membership
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.state.membershipState = membershipState
|
||||
userIndicatorController.retractIndicatorWithId(indicatorID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupSubscriptions() {
|
||||
@CancellableTask
|
||||
private var fetchUsersTask: Task<Void, Never>?
|
||||
|
||||
private func setupSubscriptions(selectedUsers: CurrentValuePublisher<[UserProfile], Never>) {
|
||||
context.$viewState
|
||||
.map(\.bindings.searchQuery)
|
||||
.debounceAndRemoveDuplicates()
|
||||
@@ -65,11 +93,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
self?.fetchUsers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
selectedUsers
|
||||
.sink { [weak self] users in
|
||||
self?.state.selectedUsers = users
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@CancellableTask
|
||||
private var fetchUsersTask: Task<Void, Never>?
|
||||
|
||||
private func fetchUsers() {
|
||||
guard searchQuery.count >= 3 else {
|
||||
fetchSuggestions()
|
||||
@@ -107,3 +138,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
context.searchQuery
|
||||
}
|
||||
}
|
||||
|
||||
private extension InviteUsersScreenRoomType {
|
||||
var isCreatingRoom: Bool {
|
||||
switch self {
|
||||
case .draft:
|
||||
return true
|
||||
case .room:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,11 @@ struct InviteUsersScreen: View {
|
||||
ForEach(context.viewState.usersSection.users, id: \.userID) { user in
|
||||
Button { context.send(viewAction: .toggleUser(user)) } label: {
|
||||
UserProfileCell(user: user,
|
||||
membership: context.viewState.membershipState(user),
|
||||
imageProvider: context.imageProvider)
|
||||
}
|
||||
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user))))
|
||||
.buttonStyle(FormButtonStyle(isDisabled: context.viewState.isUserDisabled(user),
|
||||
accessory: .selection(isSelected: context.viewState.isUserSelected(user))))
|
||||
}
|
||||
} header: {
|
||||
if let title = context.viewState.usersSection.title {
|
||||
@@ -107,8 +109,9 @@ struct InviteUsersScreen: View {
|
||||
|
||||
private var nextButton: some View {
|
||||
Button { context.send(viewAction: .proceed) } label: {
|
||||
Text(context.viewState.selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext)
|
||||
Text(context.viewState.actionText)
|
||||
}
|
||||
.disabled(context.viewState.isActionDisabled)
|
||||
}
|
||||
|
||||
private func deselect(_ user: UserProfile) {
|
||||
@@ -120,12 +123,10 @@ struct InviteUsersScreen: View {
|
||||
|
||||
struct InviteUsersScreen_Previews: PreviewProvider {
|
||||
static let viewModel = {
|
||||
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
|
||||
mediaProvider: MockMediaProvider())
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice])
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
return InviteUsersScreenViewModel(selectedUsers: .init([]), userSession: userSession, userDiscoveryService: userDiscoveryService)
|
||||
return InviteUsersScreenViewModel(selectedUsers: .init([]), roomType: .draft, mediaProvider: MockMediaProvider(), userDiscoveryService: userDiscoveryService)
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct RoomDetailsScreenCoordinatorParameters {
|
||||
let navigationStackCoordinator: NavigationStackCoordinator
|
||||
let roomProxy: RoomProxyProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
}
|
||||
|
||||
enum RoomDetailsScreenCoordinatorAction {
|
||||
@@ -30,7 +32,11 @@ enum RoomDetailsScreenCoordinatorAction {
|
||||
final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: RoomDetailsScreenCoordinatorParameters
|
||||
private var viewModel: RoomDetailsScreenViewModelProtocol
|
||||
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
private let selectedUsers: CurrentValueSubject<[UserProfile], Never> = .init([])
|
||||
private var navigationStackCoordinator: NavigationStackCoordinator {
|
||||
parameters.navigationStackCoordinator
|
||||
}
|
||||
|
||||
var callback: ((RoomDetailsScreenCoordinatorAction) -> Void)?
|
||||
|
||||
@@ -50,6 +56,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .requestMemberDetailsPresentation(let members):
|
||||
self.presentRoomMembersList(members)
|
||||
case .requestInvitePeoplePresentation(let members):
|
||||
self.presentInviteUsersScreen(members: members)
|
||||
case .cancel:
|
||||
self.callback?(.cancel)
|
||||
case .leftRoom:
|
||||
@@ -62,12 +70,96 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
AnyView(RoomDetailsScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func presentRoomMembersList(_ members: [RoomMemberProxyProtocol]) {
|
||||
let params = RoomMembersListScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
members: members)
|
||||
let coordinator = RoomMembersListScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case .invite:
|
||||
self?.presentInviteUsersScreen(members: members)
|
||||
}
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
private func presentInviteUsersScreen(members: [RoomMemberProxyProtocol]) {
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator)
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(selectedUsers: .init(selectedUsers),
|
||||
roomType: .room(members: members, userIndicatorController: userIndicatorController),
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userDiscoveryService: parameters.userDiscoveryService)
|
||||
|
||||
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
coordinator.actions.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .proceed:
|
||||
break
|
||||
case .invite(let users):
|
||||
self.inviteUsers(users, in: parameters.roomProxy)
|
||||
case .toggleUser(let user):
|
||||
self.toggleUser(user)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
|
||||
}
|
||||
|
||||
private func toggleUser(_ user: UserProfile) {
|
||||
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)
|
||||
}
|
||||
|
||||
private func inviteUsers(_ users: [String], in room: RoomProxyProtocol) {
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
|
||||
Task {
|
||||
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
|
||||
}
|
||||
|
||||
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.commonUnableToInviteTitle,
|
||||
message: L10n.commonUnableToInviteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Result {
|
||||
var isFailure: Bool {
|
||||
switch self {
|
||||
case .success:
|
||||
return false
|
||||
case .failure:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import UIKit
|
||||
|
||||
enum RoomDetailsScreenViewModelAction {
|
||||
case requestMemberDetailsPresentation([RoomMemberProxyProtocol])
|
||||
case requestInvitePeoplePresentation([RoomMemberProxyProtocol])
|
||||
case leftRoom
|
||||
case cancel
|
||||
}
|
||||
@@ -41,6 +42,7 @@ struct RoomDetailsScreenViewState: BindableState {
|
||||
var members: [RoomMemberDetails] = []
|
||||
var joinedMembersCount = 0
|
||||
var isProcessingIgnoreRequest = false
|
||||
var canInviteUsers = false
|
||||
|
||||
var isLoadingMembers: Bool {
|
||||
members.isEmpty
|
||||
@@ -124,6 +126,7 @@ struct LeaveRoomAlertItem: AlertItem {
|
||||
|
||||
enum RoomDetailsScreenViewAction {
|
||||
case processTapPeople
|
||||
case processTapInvite
|
||||
case processTapLeave
|
||||
case processTapIgnore
|
||||
case processTapUnignore
|
||||
|
||||
@@ -56,6 +56,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
switch viewAction {
|
||||
case .processTapPeople:
|
||||
callback?(.requestMemberDetailsPresentation(members))
|
||||
case .processTapInvite:
|
||||
callback?(.requestInvitePeoplePresentation(members))
|
||||
case .processTapLeave:
|
||||
let joinedMembers = members.filter { $0.membership == .join }
|
||||
guard joinedMembers.count > 1 else {
|
||||
@@ -84,27 +86,29 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
guard let self else { return }
|
||||
|
||||
buildMembersDetailsTask = Task {
|
||||
let (membersDetails, joinedMembersCount) = await self.buildMembersDetails(members: members)
|
||||
let roomMembersDetails = await self.buildMembersDetails(members: members)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if self.roomProxy.isDirect, self.roomProxy.isEncrypted, members.count == 2 {
|
||||
self.dmRecipient = members.first(where: { !$0.isAccountOwner })
|
||||
}
|
||||
|
||||
self.state.members = membersDetails
|
||||
self.state.joinedMembersCount = joinedMembersCount
|
||||
|
||||
self.state.members = roomMembersDetails.members
|
||||
self.state.joinedMembersCount = roomMembersDetails.joinedMembersCount
|
||||
self.state.dmRecipient = self.dmRecipient.map(RoomMemberDetails.init(withProxy:))
|
||||
self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false
|
||||
self.members = members
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> (memberDetails: [RoomMemberDetails], joinedMembersCount: Int) {
|
||||
private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails {
|
||||
await Task.detached {
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
var roomMembersDetails: [RoomMemberDetails] = []
|
||||
var accountOwner: RoomMemberProxyProtocol?
|
||||
var joinedMembersCount = 0
|
||||
roomMembersDetails.reserveCapacity(members.count)
|
||||
|
||||
@@ -114,9 +118,13 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
if member.membership == .join {
|
||||
joinedMembersCount += 1
|
||||
}
|
||||
|
||||
if accountOwner == nil, member.isAccountOwner {
|
||||
accountOwner = member
|
||||
}
|
||||
}
|
||||
|
||||
return (roomMembersDetails, joinedMembersCount)
|
||||
return .init(members: roomMembersDetails, joinedMembersCount: joinedMembersCount, accountOwner: accountOwner)
|
||||
}
|
||||
.value
|
||||
}
|
||||
@@ -124,7 +132,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
private static let leaveRoomLoadingID = "LeaveRoomLoading"
|
||||
|
||||
private func leaveRoom() async {
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true))
|
||||
let result = await roomProxy.leaveRoom()
|
||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
|
||||
switch result {
|
||||
@@ -159,3 +167,9 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomMembersDetails {
|
||||
let members: [RoomMemberDetails]
|
||||
let joinedMembersCount: Int
|
||||
let accountOwner: RoomMemberProxyProtocol?
|
||||
}
|
||||
|
||||
@@ -128,12 +128,22 @@ struct RoomDetailsScreen: View {
|
||||
Label(L10n.commonPeople, systemImage: "person")
|
||||
}
|
||||
}
|
||||
.buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink))
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
|
||||
.disabled(context.viewState.isLoadingMembers)
|
||||
|
||||
if context.viewState.canInviteUsers, ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled {
|
||||
Button {
|
||||
context.send(viewAction: .processTapInvite)
|
||||
} label: {
|
||||
Label(L10n.screenRoomDetailsInvitePeopleTitle, systemImage: "person.badge.plus")
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.invite)
|
||||
}
|
||||
}
|
||||
.formSectionStyle()
|
||||
.listRowSeparatorTint(.element.quinaryContent)
|
||||
.buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink))
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
.disabled(context.viewState.isLoadingMembers)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
+5
-1
@@ -22,7 +22,9 @@ struct RoomMembersListScreenCoordinatorParameters {
|
||||
let members: [RoomMemberProxyProtocol]
|
||||
}
|
||||
|
||||
enum RoomMembersListScreenCoordinatorAction { }
|
||||
enum RoomMembersListScreenCoordinatorAction {
|
||||
case invite
|
||||
}
|
||||
|
||||
final class RoomMembersListScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: RoomMembersListScreenCoordinatorParameters
|
||||
@@ -45,6 +47,8 @@ final class RoomMembersListScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case let .selectMember(member):
|
||||
self.selectMember(member)
|
||||
case .invite:
|
||||
callback?(.invite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ import Foundation
|
||||
|
||||
enum RoomMembersListScreenViewModelAction {
|
||||
case selectMember(_ member: RoomMemberProxyProtocol)
|
||||
case invite
|
||||
}
|
||||
|
||||
struct RoomMembersListScreenViewState: BindableState {
|
||||
private var joinedMembers: [RoomMemberDetails]
|
||||
private var invitedMembers: [RoomMemberDetails]
|
||||
var canInviteUsers = false
|
||||
var bindings: RoomMembersListScreenViewStateBindings
|
||||
|
||||
init(joinedMembers: [RoomMemberDetails] = [], invitedMembers: [RoomMemberDetails] = [], bindings: RoomMembersListScreenViewStateBindings = .init()) {
|
||||
@@ -59,6 +61,7 @@ struct RoomMembersListScreenViewStateBindings {
|
||||
|
||||
enum RoomMembersListScreenViewAction {
|
||||
case selectMember(id: String)
|
||||
case invite
|
||||
}
|
||||
|
||||
private extension RoomMemberDetails {
|
||||
|
||||
+32
-12
@@ -29,7 +29,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
|
||||
self.members = members
|
||||
super.init(initialViewState: .init(), imageProvider: mediaProvider)
|
||||
|
||||
buildMembersDetails(members: members)
|
||||
setupState(members: members)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -42,12 +42,14 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
|
||||
return
|
||||
}
|
||||
callback?(.selectMember(member))
|
||||
case .invite:
|
||||
callback?(.invite)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
func buildMembersDetails(members: [RoomMemberProxyProtocol]) {
|
||||
private func setupState(members: [RoomMemberProxyProtocol]) {
|
||||
Task {
|
||||
let indicatorId = UUID().uuidString
|
||||
defer {
|
||||
@@ -55,24 +57,42 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: indicatorId, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
|
||||
let (invitedMembers, joinedMembers) = await split(members: members)
|
||||
self.state = .init(joinedMembers: joinedMembers, invitedMembers: invitedMembers)
|
||||
let roomMembersDetails = await buildMembersDetails(members: members)
|
||||
self.state = .init(joinedMembers: roomMembersDetails.joinedMembers, invitedMembers: roomMembersDetails.invitedMembers)
|
||||
self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func split(members: [RoomMemberProxyProtocol]) async -> (invited: [RoomMemberDetails], joined: [RoomMemberDetails]) {
|
||||
private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails {
|
||||
await Task.detached {
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
let invitedMembers = members
|
||||
.filter { $0.membership == .invite }
|
||||
.map(RoomMemberDetails.init(withProxy:))
|
||||
var invitedMembers: [RoomMemberDetails] = .init()
|
||||
var joinedMembers: [RoomMemberDetails] = .init()
|
||||
var accountOwner: RoomMemberProxyProtocol?
|
||||
|
||||
let joinedMembers = members
|
||||
.filter { $0.membership == .join }
|
||||
.map(RoomMemberDetails.init(withProxy:))
|
||||
for member in members {
|
||||
if accountOwner == nil, member.isAccountOwner {
|
||||
accountOwner = member
|
||||
}
|
||||
|
||||
switch member.membership {
|
||||
case .invite:
|
||||
invitedMembers.append(.init(withProxy: member))
|
||||
case .join:
|
||||
joinedMembers.append(.init(withProxy: member))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return (invitedMembers, joinedMembers)
|
||||
return .init(invitedMembers: invitedMembers, joinedMembers: joinedMembers, accountOwner: accountOwner)
|
||||
}
|
||||
.value
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomMembersDetails {
|
||||
var invitedMembers: [RoomMemberDetails]
|
||||
var joinedMembers: [RoomMemberDetails]
|
||||
var accountOwner: RoomMemberProxyProtocol?
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ struct RoomMembersListScreen: View {
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.navigationTitle(L10n.commonPeople)
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
inviteButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@@ -53,6 +58,17 @@ struct RoomMembersListScreen: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var inviteButton: some View {
|
||||
if context.viewState.canInviteUsers, ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled {
|
||||
Button {
|
||||
context.send(viewAction: .invite)
|
||||
} label: {
|
||||
Text(L10n.actionInvite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -22,6 +22,7 @@ struct RoomScreenCoordinatorParameters {
|
||||
let timelineController: RoomTimelineControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let emojiProvider: EmojiProviderProtocol
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
}
|
||||
|
||||
enum RoomScreenCoordinatorAction {
|
||||
@@ -163,7 +164,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
private func displayRoomDetails() {
|
||||
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: parameters.roomProxy,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userDiscoveryService: parameters.userDiscoveryService)
|
||||
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
|
||||
@@ -80,18 +80,19 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
|
||||
// MARK: - Private
|
||||
|
||||
private func presentInviteUsersScreen() {
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: parameters.userSession,
|
||||
userDiscoveryService: parameters.userDiscoveryService,
|
||||
selectedUsers: selectedUsersPublisher)
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(selectedUsers: selectedUsersPublisher,
|
||||
roomType: .draft,
|
||||
mediaProvider: parameters.userSession.mediaProvider,
|
||||
userDiscoveryService: parameters.userDiscoveryService)
|
||||
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
|
||||
coordinator.actions.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .close:
|
||||
parameters.navigationStackCoordinator?.pop()
|
||||
case .proceed:
|
||||
openCreateRoomScreen()
|
||||
case .invite:
|
||||
break
|
||||
case .toggleUser(let user):
|
||||
toggleUser(user)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ struct StartChatScreen: View {
|
||||
Section {
|
||||
ForEach(context.viewState.usersSection.users, id: \.userID) { user in
|
||||
Button { context.send(viewAction: .selectUser(user)) } label: {
|
||||
UserProfileCell(user: user, imageProvider: context.imageProvider)
|
||||
UserProfileCell(user: user, membership: nil, imageProvider: context.imageProvider)
|
||||
}
|
||||
.buttonStyle(FormButtonStyle())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user