refactor: manage member sheet in timeline

better implementation

updated tests

Update ElementX/Sources/Screens/Timeline/TimelineViewModel.swift

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

fix
This commit is contained in:
Mauro Romito
2025-04-08 15:23:40 +02:00
committed by Mauro
parent 56476f8bb5
commit 0e7533145a
44 changed files with 760 additions and 344 deletions
@@ -0,0 +1,33 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
enum ManageRoomMemberSheetViewModelAction: Equatable {
case dismiss(shouldShowDetails: Bool)
}
struct ManageRoomMemberSheetViewState: BindableState {
let member: RoomMemberDetails
let canKick: Bool
let canBan: Bool
var bindings = ManageRoomMemberSheetViewStateBindings()
}
struct ManageRoomMemberSheetViewStateBindings {
var alertInfo: AlertInfo<ManageRoomMemberSheetViewAlertType>?
}
enum ManageRoomMemberSheetViewAlertType {
case kick
case ban
}
enum ManageRoomMemberSheetViewAction {
case kick
case ban
case displayDetails
}
@@ -0,0 +1,125 @@
//
// Copyright 2025 New Vector 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 Foundation
import SwiftUI
typealias ManageRoomMemberSheetViewModelType = StateStoreViewModel<ManageRoomMemberSheetViewState, ManageRoomMemberSheetViewAction>
class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, ManageRoomMemberSheetViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analyticsService: AnalyticsService
private var actionsSubject: PassthroughSubject<ManageRoomMemberSheetViewModelAction, Never> = .init()
var actions: AnyPublisher<ManageRoomMemberSheetViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(member: RoomMemberDetails,
canKick: Bool,
canBan: Bool,
roomProxy: JoinedRoomProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
analyticsService: AnalyticsService,
mediaProvider: MediaProviderProtocol) {
self.userIndicatorController = userIndicatorController
self.roomProxy = roomProxy
self.analyticsService = analyticsService
super.init(initialViewState: .init(member: member, canKick: canKick, canBan: canBan), mediaProvider: mediaProvider)
}
override func process(viewAction: ManageRoomMemberSheetViewAction) {
switch viewAction {
case .kick:
displayAlert(.kick)
case .ban:
displayAlert(.ban)
case .displayDetails:
actionsSubject.send(.dismiss(shouldShowDetails: true))
}
}
private func displayAlert(_ alertType: ManageRoomMemberSheetViewAlertType) {
let member = state.member
var reason: String?
let binding: Binding<String> = .init(get: { reason ?? "" },
set: { reason = $0.isBlank ? nil : $0 })
switch alertType {
case .kick:
state.bindings.alertInfo = .init(id: alertType,
title: L10n.screenBottomSheetManageRoomMemberKickMemberConfirmationTitle,
message: L10n.screenBottomSheetManageRoomMemberKickMemberConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel) { },
secondaryButton: .init(title: L10n.screenBottomSheetManageRoomMemberKickMemberConfirmationAction) { [weak self] in Task { await self?.kickMember(member, reason: reason) } },
textFields: [.init(placeholder: L10n.commonReason,
text: binding,
autoCapitalization: .sentences,
autoCorrectionDisabled: false)])
case .ban:
state.bindings.alertInfo = .init(id: alertType,
title: L10n.screenBottomSheetManageRoomMemberBanMemberConfirmationTitle,
message: L10n.screenBottomSheetManageRoomMemberBanMemberConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel) { },
secondaryButton: .init(title: L10n.screenBottomSheetManageRoomMemberBanMemberConfirmationAction) { [weak self] in Task { await self?.banMember(member, reason: reason) } },
textFields: [.init(placeholder: L10n.commonReason,
text: binding,
autoCapitalization: .sentences,
autoCorrectionDisabled: false)])
}
}
private func kickMember(_ member: RoomMemberDetails, reason: String?) async {
let indicatorTitle = L10n.screenBottomSheetManageRoomMemberRemovingUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.kickUser(member.id, reason: reason) {
case .success:
hideManageMemberIndicator(title: indicatorTitle)
analyticsService.trackRoomModeration(action: .KickMember, role: nil)
actionsSubject.send(.dismiss(shouldShowDetails: false))
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func banMember(_ member: RoomMemberDetails, reason: String?) async {
let indicatorTitle = L10n.screenBottomSheetManageRoomMemberBanningUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.banUser(member.id, reason: reason) {
case .success:
hideManageMemberIndicator(title: indicatorTitle)
analyticsService.trackRoomModeration(action: .BanMember, role: nil)
actionsSubject.send(.dismiss(shouldShowDetails: false))
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func showManageMemberIndicator(title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: title,
type: .toast(progress: .indeterminate),
title: title,
persistent: true))
}
private func hideManageMemberIndicator(title: String) {
userIndicatorController.retractIndicatorWithId(title)
}
private func showManageMemberFailure(title: String) {
userIndicatorController.retractIndicatorWithId(title)
userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonFailed, iconName: "xmark"))
}
}
extension ManageRoomMemberSheetViewModel: Identifiable {
var id: String { state.member.id }
}
@@ -0,0 +1,14 @@
//
// Copyright 2025 New Vector 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
@MainActor
protocol ManageRoomMemberSheetViewModelProtocol {
var actions: AnyPublisher<ManageRoomMemberSheetViewModelAction, Never> { get }
var context: ManageRoomMemberSheetViewModelType.Context { get }
}
@@ -0,0 +1,87 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct ManageRoomMemberSheetView: View {
@ObservedObject var context: ManageRoomMemberSheetViewModelType.Context
var body: some View {
Form {
AvatarHeaderView(member: context.viewState.member,
avatarSize: .user(on: .memberDetails),
mediaProvider: context.mediaProvider) {
EmptyView()
}
Section {
ListRow(label: .default(title: L10n.screenBottomSheetManageRoomMemberMemberUserInfo,
icon: \.userProfileSolid),
kind: .navigationLink {
context.send(viewAction: .displayDetails)
})
}
Section {
if context.viewState.canKick {
ListRow(label: .default(title: L10n.screenBottomSheetManageRoomMemberRemove,
icon: \.close,
role: .destructive),
kind: .button {
context.send(viewAction: .kick)
})
}
if context.viewState.canBan {
ListRow(label: .default(title: L10n.screenBottomSheetManageRoomMemberBan,
icon: \.block,
role: .destructive),
kind: .button {
context.send(viewAction: .ban)
})
}
}
}
.compoundList()
.scrollBounceBehavior(.basedOnSize)
.presentationDragIndicator(.visible)
.presentationDetents([.large, .fraction(0.67)]) // Maybe find a way to use the ideal height somehow?
.alert(item: $context.alertInfo)
}
}
struct ManageRoomMemberSheetView_Previews: PreviewProvider, TestablePreview {
static let allActionsViewModel = ManageRoomMemberSheetViewModel.mock()
static let kickOnlyViewModel = ManageRoomMemberSheetViewModel.mock(canBan: false)
static let banOnlyViewModel = ManageRoomMemberSheetViewModel.mock(canKick: false)
static var previews: some View {
ManageRoomMemberSheetView(context: allActionsViewModel.context)
.previewDisplayName("All Actions")
ManageRoomMemberSheetView(context: kickOnlyViewModel.context)
.previewDisplayName("Kick Only")
ManageRoomMemberSheetView(context: banOnlyViewModel.context)
.previewDisplayName("Ban Only")
}
}
private extension ManageRoomMemberSheetViewModel {
static func mock(canKick: Bool = true,
canBan: Bool = true) -> ManageRoomMemberSheetViewModel {
let member = RoomMemberDetails(withProxy: RoomMemberProxyMock.mockDan)
return ManageRoomMemberSheetViewModel(member: member,
canKick: canKick,
canBan: canBan,
roomProxy: JoinedRoomProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
analyticsService: ServiceLocator.shared.analytics,
mediaProvider: MediaProviderMock(configuration: .init()))
}
}
@@ -80,7 +80,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
@@ -102,7 +102,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
@@ -78,7 +78,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .tappedOnSenderDetails(let userID):
case .displaySenderDetails(let userID):
actionsSubject.send(.displayUser(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
@@ -28,6 +28,9 @@ struct PinnedEventsTimelineScreen: View {
.background(.compound.bgCanvasDefault)
.interactiveDismissDisabled()
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.sheet(item: $timelineContext.manageMemberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
@@ -80,31 +80,15 @@ struct RoomMembersListScreenViewStateBindings {
var searchQuery = ""
/// The current mode the screen is in.
var mode: RoomMembersListScreenMode = .members
/// A selected member to kick, ban, promote etc.
var memberToManage: RoomMembersListScreenManagementDetails?
/// A sheet model for the selected member to kick, ban, promote etc.
var manageMemeberViewModel: ManageRoomMemberSheetViewModel?
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomMembersListScreenAlertType>?
}
/// Information about managing a particular room member.
struct RoomMembersListScreenManagementDetails: Identifiable {
var id: String { member.id }
/// The member that is being managed.
let member: RoomMemberDetails
/// A management action that can be performed on the member.
enum Action { case kick, ban }
/// The management actions available for `member`.
let actions: [Action]
}
enum RoomMembersListScreenViewAction {
case selectMember(RoomMemberDetails)
case showMemberDetails(RoomMemberDetails)
case kickMember(RoomMemberDetails)
case banMember(RoomMemberDetails)
case unbanMember(RoomMemberDetails)
case invite
}
@@ -15,8 +15,10 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
private let mediaProvider: MediaProviderProtocol
private var members: [RoomMemberProxyProtocol] = []
private var currentUserProxy: RoomMemberProxyProtocol?
private var actionsSubject: PassthroughSubject<RoomMembersListScreenViewModelAction, Never> = .init()
@@ -34,6 +36,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.analytics = analytics
self.mediaProvider = mediaProvider
super.init(initialViewState: .init(joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount,
bindings: .init(mode: initialMode)),
@@ -48,34 +51,6 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
switch viewAction {
case .selectMember(let member):
selectMember(member)
case .showMemberDetails(let member):
showMemberDetails(member)
case .kickMember(let member):
var reason: String?
let binding: Binding<String> = .init(get: { reason ?? "" },
set: { reason = $0.isBlank ? nil : $0 })
state.bindings.alertInfo = .init(id: .kickConfirmation,
title: L10n.screenRoomMemberListKickMemberConfirmationTitle,
message: L10n.screenRoomMemberListKickMemberConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel) { },
secondaryButton: .init(title: L10n.screenRoomMemberListKickMemberConfirmationAction) { [weak self] in Task { await self?.kickMember(member, reason: reason) } },
textFields: [.init(placeholder: L10n.commonReason,
text: binding,
autoCapitalization: .sentences,
autoCorrectionDisabled: false)])
case .banMember(let member):
var reason: String?
let binding: Binding<String> = .init(get: { reason ?? "" },
set: { reason = $0.isBlank ? nil : $0 })
state.bindings.alertInfo = .init(id: .banConfirmation,
title: L10n.screenRoomMemberListBanMemberConfirmationTitle,
message: L10n.screenRoomMemberListBanMemberConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel) { },
secondaryButton: .init(title: L10n.screenRoomMemberListBanMemberConfirmationAction) { [weak self] in Task { await self?.banMember(member, reason: reason) } },
textFields: [.init(placeholder: L10n.commonReason,
text: binding,
autoCapitalization: .sentences,
autoCorrectionDisabled: false)])
case .unbanMember(let member):
Task { await unbanMember(member) }
case .invite:
@@ -118,6 +93,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
let members = members.sorted()
let roomMembersDetails = await buildMembersDetails(members: members)
self.members = members
self.currentUserProxy = members.first { $0.userID == roomProxy.ownUserID }
self.state = .init(joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount,
joinedMembers: roomMembersDetails.joinedMembers,
@@ -168,6 +144,12 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
}
private func selectMember(_ member: RoomMemberDetails) {
guard let currentUserProxy,
currentUserProxy.powerLevel > member.powerLevel else {
showMemberDetails(member)
return
}
if member.isBanned { // No need to check canBan here, banned users are only shown when it is true.
state.bindings.alertInfo = AlertInfo(id: .unbanConfirmation(member),
title: L10n.screenRoomMemberListManageMemberUnbanTitle,
@@ -179,16 +161,26 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
return
}
var actions = [RoomMembersListScreenManagementDetails.Action]()
if state.canKickUsers, member.role != .administrator {
actions.append(.kick)
}
if state.canBanUsers, member.role != .administrator {
actions.append(.ban)
}
if !actions.isEmpty {
state.bindings.memberToManage = .init(member: member, actions: actions)
if state.canKickUsers || state.canBanUsers {
let manageMemeberViewModel = ManageRoomMemberSheetViewModel(member: member,
canKick: state.canKickUsers,
canBan: state.canBanUsers,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
analyticsService: analytics,
mediaProvider: mediaProvider)
manageMemeberViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss(let shouldShowDetails):
state.bindings.manageMemeberViewModel = nil
if shouldShowDetails {
showMemberDetails(member)
}
}
}
.store(in: &cancellables)
state.bindings.manageMemeberViewModel = manageMemeberViewModel
} else {
showMemberDetails(member)
}
@@ -200,46 +192,16 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
return
}
actionsSubject.send(.selectMember(member))
state.bindings.memberToManage = nil
}
// MARK: - Member Management
private func kickMember(_ member: RoomMemberDetails, reason: String?) async {
let indicatorTitle = L10n.screenRoomMemberListRemovingUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.kickUser(member.id, reason: reason) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
analytics.trackRoomModeration(action: .KickMember, role: nil)
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func banMember(_ member: RoomMemberDetails, reason: String?) async {
let indicatorTitle = L10n.screenRoomMemberListBanningUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.banUser(member.id, reason: reason) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
analytics.trackRoomModeration(action: .BanMember, role: nil)
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func unbanMember(_ member: RoomMemberDetails) async {
let indicatorTitle = L10n.screenRoomMemberListUnbanningUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.unbanUser(member.id) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
analytics.trackRoomModeration(action: .UnbanMember, role: nil)
case .failure:
@@ -1,96 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct RoomMembersListManageMemberSheet: View {
let member: RoomMemberDetails
let actions: [RoomMembersListScreenManagementDetails.Action]
@ObservedObject var context: RoomMembersListScreenViewModel.Context
var body: some View {
Form {
AvatarHeaderView(member: member,
avatarSize: .user(on: .memberDetails),
mediaProvider: context.mediaProvider) {
EmptyView()
}
Section {
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberUserInfo,
icon: \.userProfileSolid),
kind: .button {
context.send(viewAction: .showMemberDetails(member))
})
if actions.contains(.kick) {
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberRemove,
icon: \.close),
kind: .button {
context.send(viewAction: .kickMember(member))
})
}
if actions.contains(.ban) {
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberBan,
icon: \.block,
role: .destructive),
kind: .button {
context.send(viewAction: .banMember(member))
})
}
}
}
.compoundList()
.scrollBounceBehavior(.basedOnSize)
.presentationDragIndicator(.visible)
.presentationDetents([.large, .fraction(0.54)]) // Maybe find a way to use the ideal height somehow?
}
}
struct RoomMembersListManageMemberSheet_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomMembersListScreenViewModel.mock
static var previews: some View {
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockDan),
actions: [.kick, .ban],
context: viewModel.context)
.previewDisplayName("Joined")
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockBanned[3]),
actions: [],
context: viewModel.context)
.previewDisplayName("Banned")
}
}
struct RoomMembersListManageMemberSheetLive_Previews: PreviewProvider {
static let viewModel = RoomMembersListScreenViewModel.mock
static var previews: some View {
Color.clear
.sheet(isPresented: .constant(true)) {
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockDan),
actions: [.kick, .ban],
context: viewModel.context)
}
.previewDisplayName("Sheet")
}
}
private extension RoomMembersListScreenViewModel {
static var mock: RoomMembersListScreenViewModel {
RoomMembersListScreenViewModel(initialMode: .members,
clientProxy: ClientProxyMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(members: .allMembersAsAdmin)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
}
}
@@ -47,8 +47,8 @@ struct RoomMembersListScreen: View {
.autocorrectionDisabled()
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationTitle(L10n.commonPeople)
.sheet(item: $context.memberToManage) {
RoomMembersListManageMemberSheet(member: $0.member, actions: $0.actions, context: context)
.sheet(item: $context.manageMemeberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}
.alert(item: $context.alertInfo)
.toolbar { toolbar }
@@ -136,7 +136,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
case .tappedOnSenderDetails(userID: let userID):
case .displaySenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
@@ -66,6 +66,9 @@ struct RoomScreen: View {
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
.alert(item: $timelineContext.alertInfo)
.sheet(item: $timelineContext.manageMemberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
@@ -19,7 +19,7 @@ enum TimelineViewModelAction {
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case tappedOnSenderDetails(userID: String)
case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
@@ -99,6 +99,8 @@ struct TimelineViewState: BindableState {
var canCurrentUserRedactOthers = false
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var canCurrentUserKick = false
var canCurrentUserBan = false
var isViewSourceEnabled: Bool
var hideTimelineMedia: Bool
@@ -135,7 +137,7 @@ struct TimelineViewStateBindings {
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var alertInfo: AlertInfo<TimelineAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
@@ -144,6 +146,8 @@ struct TimelineViewStateBindings {
var reactionSummaryInfo: ReactionSummaryInfo?
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
var manageMemberViewModel: ManageRoomMemberSheetViewModel?
}
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
@@ -172,7 +176,7 @@ struct ReadReceiptSummaryInfo: Identifiable {
let id: TimelineItemIdentifier
}
enum RoomScreenAlertInfoType: Hashable {
enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
@@ -42,6 +42,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
actionsSubject.eraseToAnyPublisher()
}
private var currentUserProxy: RoomMemberProxyProtocol?
private var paginateBackwardsTask: Task<Void, Never>?
private var paginateForwardsTask: Task<Void, Never>?
@@ -173,7 +175,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .handleTimelineItemMenuAction(let itemID, let action):
timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID)
case .tappedOnSenderDetails(userID: let userID):
actionsSubject.send(.tappedOnSenderDetails(userID: userID))
handleTappedOnSenderDetails(userID: userID)
case .displayEmojiPicker(let itemID):
timelineInteractionHandler.displayEmojiPicker(for: itemID)
case .displayReactionSummary(let itemID, let key):
@@ -258,6 +260,41 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Private
private func handleTappedOnSenderDetails(userID: String) {
// We also need to make sure the user is in the joined state, otherwise we just show the details.
guard let memberProxy = roomProxy.membersPublisher.value.first(where: { $0.userID == userID && $0.membership == .join }),
let currentUserProxy,
currentUserProxy.powerLevel > memberProxy.powerLevel else {
actionsSubject.send(.displaySenderDetails(userID: userID))
return
}
if state.canCurrentUserBan || state.canCurrentUserKick {
let member = RoomMemberDetails(withProxy: memberProxy)
let manageMemeberViewModel = ManageRoomMemberSheetViewModel(member: member,
canKick: state.canCurrentUserKick,
canBan: state.canCurrentUserBan,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
analyticsService: analyticsService,
mediaProvider: mediaProvider)
manageMemeberViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss(let shouldShowDetails):
state.bindings.manageMemberViewModel = nil
if shouldShowDetails {
actionsSubject.send(.displaySenderDetails(userID: userID))
}
}
}
.store(in: &cancellables)
state.bindings.manageMemberViewModel = manageMemeberViewModel
} else {
actionsSubject.send(.displaySenderDetails(userID: userID))
}
}
private func focusLive() {
timelineController.focusLive()
}
@@ -348,6 +385,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
state.members = members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
dictionary[member.userID] = RoomMemberState(displayName: member.displayName, avatarURL: member.avatarURL)
if member.userID == roomProxy.ownUserID {
currentUserProxy = member
}
}
}
@@ -369,6 +409,18 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} else {
state.canCurrentUserPin = false
}
if case let .success(value) = await roomProxy.canUserKick(userID: roomProxy.ownUserID) {
state.canCurrentUserKick = value
} else {
state.canCurrentUserKick = false
}
if case let .success(value) = await roomProxy.canUserBan(userID: roomProxy.ownUserID) {
state.canCurrentUserBan = value
} else {
state.canCurrentUserBan = false
}
}
private func setupSubscriptions() {
@@ -898,7 +950,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
userIndicatorController.retractIndicatorWithId(Constants.focusTimelineToastIndicatorID)
}
private func displayAlert(_ type: RoomScreenAlertInfoType) {
private func displayAlert(_ type: TimelineAlertInfoType) {
switch type {
case .audioRecodingPermissionError:
state.bindings.alertInfo = .init(id: type,