Last Owner should edit admins, and not leave when is last (#4372)

This commit is contained in:
Mauro
2025-08-07 10:40:14 +02:00
committed by GitHub
parent be525e6a60
commit 5ab6f90863
67 changed files with 691 additions and 363 deletions
@@ -242,6 +242,10 @@
"common_poll_undisclosed_text" = "Results will show after the poll has ended";
"common_preparing" = "Preparing…";
"common_privacy_policy" = "Privacy policy";
"common_private_room" = "Private room";
"common_private_space" = "Private space";
"common_public_room" = "Public room";
"common_public_space" = "Public space";
"common_reaction" = "Reaction";
"common_reactions" = "Reactions";
"common_reason" = "Reason";
@@ -556,6 +560,7 @@
"screen_room_pinned_banner_view_all_button_title" = "View All";
"screen_room_single_knock_request_title" = "%1$@ wants to join this room";
"screen_room_single_knock_request_view_button_title" = "View";
"screen_room_change_role_administrators_or_owners_title" = "Edit Admins or Owners";
"screen_room_details_pinned_events_row_title" = "Pinned messages";
"screen_room_details_profile_row_title" = "Profile";
"screen_room_details_requests_to_join_title" = "Requests to join";
@@ -714,7 +719,6 @@
"screen_create_room_action_create_room" = "New room";
"screen_create_room_error_creating_room" = "An error occurred when creating the room";
"screen_create_room_private_option_description" = "Only people invited can access this room. All messages are end-to-end encrypted.";
"screen_create_room_private_option_title" = "Private room";
"screen_create_room_public_option_description" = "Anyone can find this room.\nYou can change this anytime in room settings.";
"screen_create_room_topic_label" = "Topic (optional)";
"screen_deactivate_account_confirmation_dialog_content" = "Please confirm that you want to deactivate your account. This action cannot be undone.";
@@ -952,7 +956,6 @@
"screen_room_details_already_invited" = "Already invited";
"screen_room_details_badge_encrypted" = "Encrypted";
"screen_room_details_badge_not_encrypted" = "Not encrypted";
"screen_room_details_badge_public" = "Public room";
"screen_room_details_edit_room_title" = "Edit Room";
"screen_room_details_edition_error" = "There was an unknown error and the information couldn't be changed.";
"screen_room_details_edition_error_title" = "Unable to update room";
@@ -1295,6 +1298,7 @@
"screen_create_account_title" = "Create account";
"screen_create_poll_cancel_confirmation_content_ios" = "Your changes won’t be saved";
"screen_create_room_add_people_title" = "Invite people";
"screen_create_room_private_option_title" = "Private room";
"screen_create_room_public_option_title" = "Public room";
"screen_create_room_room_name_label" = "Room name";
"screen_create_room_title" = "Create a room";
@@ -1324,6 +1328,7 @@
"screen_room_change_role_section_moderators" = "Moderators";
"screen_room_change_role_section_users" = "Members";
"screen_room_change_role_unsaved_changes_title" = "Save changes?";
"screen_room_details_badge_public" = "Public room";
"screen_room_details_invite_people_title" = "Invite people";
"screen_room_details_leave_conversation_title" = "Leave conversation";
"screen_room_details_leave_room_title" = "Leave room";
@@ -77,9 +77,9 @@
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%1$d member</string>
<string>%1$d Member</string>
<key>other</key>
<string>%1$d members</string>
<string>%1$d Members</string>
</dict>
</dict>
<key>common_poll_votes_count</key>
@@ -77,9 +77,9 @@
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%1$d membro</string>
<string>%1$d Membro</string>
<key>other</key>
<string>%1$d membri</string>
<string>%1$d Membri</string>
</dict>
</dict>
<key>common_poll_votes_count</key>
@@ -48,6 +48,8 @@ enum AppRoute: Hashable {
case chatBackupSettings
/// An external share request e.g. from the ShareExtension
case share(ShareExtensionPayload)
/// The change roles screen of a room with the transfer ownership setting
case transferOwnership(roomID: String)
/// Whether or not the route should be handled by the authentication flow.
var isAuthenticationRoute: Bool {
@@ -202,6 +202,12 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
}
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .transferOwnership(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership))
}
}
}
@@ -232,6 +238,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
case .roomDetails: .roomDetails(roomID: roomID)
case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored.
case .share(let payload): .share(payload)
case .transferOwnership: .transferOwnership(roomID: roomID)
}
roomFlowCoordinator.handleAppRoute(route, animated: animated)
} else {
@@ -449,6 +456,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.logout)
case .presentDeclineAndBlock(let userID, let roomID):
stateMachine.processEvent(.presentDeclineAndBlockScreen(userID: userID, roomID: roomID))
case .transferOwnership(let roomIdentifier):
handleAppRoute(.transferOwnership(roomID: roomIdentifier), animated: true)
}
}
.store(in: &cancellables)
@@ -555,6 +564,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: animated)
case .share(let payload):
coordinator.handleAppRoute(.share(payload), animated: animated)
case .transferOwnership:
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: animated)
}
Task {
@@ -82,7 +82,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .genericCallLink, .settings, .share:
.call, .genericCallLink, .settings, .share, .transferOwnership:
// These routes aren't in this flow so clear the entire stack.
clearRoute(animated: animated)
case .chatBackupSettings:
@@ -37,6 +37,8 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
case roomDetails
/// An external media share request
case share(ShareExtensionPayload)
/// The flow to change the the owner of the room
case transferOwnership
var isEventID: Bool {
guard case .eventID = self else { return false }
@@ -196,9 +198,44 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break // These are converted to a room ID route one level above.
case .accountProvisioningLink, .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
break // These routes can't be handled.
case .transferOwnership(let roomID):
guard self.roomID == roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
Task {
if roomProxy == nil {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
return
}
await storeAndSubscribeToRoomProxy(roomProxy)
}
presentTransferOwnershipScreen()
}
}
}
private func presentTransferOwnershipScreen() {
let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner,
roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController,
analytics: analytics)
let stackCoordinator = NavigationStackCoordinator()
let coordinator = RoomChangeRolesScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .complete:
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
stackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(stackCoordinator, animated: true)
}
private func presentCallScreen(roomID: String) async {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
return
@@ -798,6 +835,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID))
case .presentReportRoomScreen:
stateMachine.tryEvent(.presentReportRoomScreen)
case .transferOwnership:
presentTransferOwnershipScreen()
}
}
.store(in: &cancellables)
@@ -1555,6 +1594,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .share(let payload):
coordinator.handleAppRoute(.share(payload), animated: true)
case .transferOwnership:
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true)
}
}
@@ -107,7 +107,13 @@ class RoomRolesAndPermissionsFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.addRoutes(event: .changeRoles, transitions: [.rolesAndPermissionsScreen => .changingRoles]) { [weak self] context in
guard let role = context.userInfo as? RoomRolesAndPermissionsScreenRole else { fatalError("Expected a role") }
self?.presentChangeRolesScreen(role: role)
let mode: RoomRole = switch role {
case .administrators:
.administrator
case .moderators:
.moderator
}
self?.presentChangeRolesScreen(mode: mode)
}
stateMachine.addRoutes(event: .finishedChangingRoles, transitions: [.changingRoles => .rolesAndPermissionsScreen])
@@ -150,12 +156,7 @@ class RoomRolesAndPermissionsFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentChangeRolesScreen(role: RoomRolesAndPermissionsScreenRole) {
let mode = switch role {
case .administrators: RoomMemberDetails.Role.administrator
case .moderators: RoomMemberDetails.Role.moderator
}
private func presentChangeRolesScreen(mode: RoomRole) {
let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: mode,
roomProxy: roomProxy,
mediaProvider: mediaProvider,
+10
View File
@@ -542,6 +542,14 @@ internal enum L10n {
internal static var commonPreparing: String { return L10n.tr("Localizable", "common_preparing") }
/// Privacy policy
internal static var commonPrivacyPolicy: String { return L10n.tr("Localizable", "common_privacy_policy") }
/// Private room
internal static var commonPrivateRoom: String { return L10n.tr("Localizable", "common_private_room") }
/// Private space
internal static var commonPrivateSpace: String { return L10n.tr("Localizable", "common_private_space") }
/// Public room
internal static var commonPublicRoom: String { return L10n.tr("Localizable", "common_public_room") }
/// Public space
internal static var commonPublicSpace: String { return L10n.tr("Localizable", "common_public_space") }
/// Reaction
internal static var commonReaction: String { return L10n.tr("Localizable", "common_reaction") }
/// Reactions
@@ -2150,6 +2158,8 @@ internal enum L10n {
internal static var screenRoomChangePermissionsRoomTopic: String { return L10n.tr("Localizable", "screen_room_change_permissions_room_topic") }
/// Send messages
internal static var screenRoomChangePermissionsSendMessages: String { return L10n.tr("Localizable", "screen_room_change_permissions_send_messages") }
/// Edit Admins or Owners
internal static var screenRoomChangeRoleAdministratorsOrOwnersTitle: String { return L10n.tr("Localizable", "screen_room_change_role_administrators_or_owners_title") }
/// Edit Admins
internal static var screenRoomChangeRoleAdministratorsTitle: String { return L10n.tr("Localizable", "screen_room_change_role_administrators_title") }
/// You will not be able to undo this action. You are promoting the user to have the same power level as you.
@@ -13334,11 +13334,6 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol, @unchecked Sendable {
set(value) { underlyingPowerLevel = value }
}
var underlyingPowerLevel: RoomPowerLevel!
var role: RoomMemberRole {
get { return underlyingRole }
set(value) { underlyingRole = value }
}
var underlyingRole: RoomMemberRole!
}
class RoomMembershipDetailsProxyMock: RoomMembershipDetailsProxyProtocol, @unchecked Sendable {
@@ -82,7 +82,7 @@ private extension RoomMember {
powerLevel: proxy.powerLevel.rustPowerLevel,
normalizedPowerLevel: proxy.powerLevel.rustPowerLevel,
isIgnored: proxy.isIgnored,
suggestedRoleForPowerLevel: proxy.role,
suggestedRoleForPowerLevel: proxy.role.rustRole,
membershipChangeReason: proxy.membershipChangeReason)
}
}
@@ -89,7 +89,7 @@ extension JoinedRoomProxyMock {
guard case .success(let member) = await self?.getMember(userID: userID) else {
return .failure(.sdkError(RoomProxyMockError.generic))
}
return .success(member.role)
return .success(member.role.rustRole)
}
updatePowerLevelsForUsersReturnValue = .success(())
@@ -17,7 +17,6 @@ struct RoomMemberProxyMockConfiguration {
var isIgnored = false
var powerLevel = RoomPowerLevel(value: 0)
var role = RoomMemberRole.user
}
extension RoomMemberProxyMock {
@@ -36,7 +35,6 @@ extension RoomMemberProxyMock {
isIgnored = configuration.isIgnored
powerLevel = configuration.powerLevel
role = configuration.role
}
// Mocks
@@ -52,8 +50,15 @@ extension RoomMemberProxyMock {
displayName: "Me",
avatarURL: .mockMXCUserAvatar,
membership: .join,
powerLevel: .init(value: 100),
role: .administrator))
powerLevel: .init(value: 100)))
}
static var mockMeCreator: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@me:matrix.org",
displayName: "Me",
avatarURL: .mockMXCUserAvatar,
membership: .join,
powerLevel: .infinite))
}
static var mockAlice: RoomMemberProxyMock {
@@ -116,32 +121,28 @@ extension RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@admin:matrix.org",
displayName: "Arthur",
membership: .join,
powerLevel: .init(value: 100),
role: .administrator))
powerLevel: .init(value: 100)))
}
static var mockCreator: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@creator:matrix.org",
displayName: "God",
membership: .join,
powerLevel: .infinite,
role: .creator))
powerLevel: .infinite))
}
static var mockOwner: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@owner:matrix.org",
displayName: "Guinevere",
membership: .join,
powerLevel: .value(150),
role: .administrator))
powerLevel: .value(150)))
}
static var mockModerator: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@mod:matrix.org",
displayName: "Merlin",
membership: .join,
powerLevel: .init(value: 50),
role: .moderator))
powerLevel: .init(value: 50)))
}
static var mockBanned: [RoomMemberProxyMock] {
@@ -181,4 +182,33 @@ extension Array where Element == RoomMemberProxyMock {
.mockAdmin,
.mockModerator
]
/// This also includes the creator and the owner role.
static let allMembersAsAdminV2: [RoomMemberProxyMock] = [
.mockMeAdmin,
.mockAlice,
.mockBob,
.mockCharlie,
.mockDan,
.mockInvited,
.mockIgnored,
.mockAdmin,
.mockModerator,
.mockOwner,
.mockCreator
]
static let allMembersAsCreator: [RoomMemberProxyMock] = [
.mockAdmin,
.mockAlice,
.mockBob,
.mockCharlie,
.mockDan,
.mockInvited,
.mockIgnored,
.mockModerator,
.mockCreator,
.mockMeCreator,
.mockOwner
]
}
@@ -24,6 +24,7 @@ enum HomeScreenCoordinatorAction {
case presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String)
case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String)
case presentSettingsScreen
case presentFeedbackScreen
case presentSecureBackupSettings
@@ -86,6 +87,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.logout)
case .presentDeclineAndBlock(let userID, let roomID):
actionsSubject.send(.presentDeclineAndBlock(userID: userID, roomID: roomID))
case .transferOwnership(let roomIdentifier):
actionsSubject.send(.transferOwnership(roomIdentifier: roomIdentifier))
}
}
.store(in: &cancellables)
@@ -15,6 +15,7 @@ enum HomeScreenViewModelAction: Equatable {
case presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String)
case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String)
case presentSecureBackupSettings
case presentRecoveryKeyScreen
case presentEncryptionResetScreen
@@ -342,15 +342,37 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
return
}
if !(roomProxy.infoPublisher.value.isPrivate ?? true) {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .public)
} else {
state.bindings.leaveRoomAlertItem = if roomProxy.infoPublisher.value.joinedMembersCount > 1 {
LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .private)
} else {
LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: .empty)
guard roomProxy.infoPublisher.value.joinedMembersCount > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID,
isDM: roomProxy.isDirectOneToOneRoom,
state: roomProxy.infoPublisher.value.isPrivate ?? true ? .empty : .public)
return
}
if !roomProxy.isDirectOneToOneRoom {
if case let .success(ownMember) = await roomProxy.getMember(userID: roomProxy.ownUserID),
ownMember.role.isOwner {
await roomProxy.updateMembers()
var isLastOwner = true
for member in roomProxy.membersPublisher.value where member.userID != roomProxy.ownUserID {
if member.role.isOwner {
isLastOwner = false
break
}
}
if isLastOwner {
state.bindings.alertInfo = .init(id: UUID(),
title: L10n.leaveRoomAlertSelectNewOwnerTitle,
message: L10n.leaveRoomAlertSelectNewOwnerSubtitle,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.leaveRoomAlertSelectNewOwnerAction, role: .destructive, action: { [weak self] in self?.actionsSubject.send(.transferOwnership(roomIdentifier: roomID)) }))
return
}
}
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomID, isDM: roomProxy.isDirectOneToOneRoom, state: roomProxy.infoPublisher.value.isPrivate ?? true ? .private : .public)
}
}
@@ -9,7 +9,7 @@ import Combine
import SwiftUI
struct RoomChangeRolesScreenCoordinatorParameters {
let mode: RoomMemberDetails.Role
let mode: RoomRole
let roomProxy: JoinedRoomProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
@@ -14,15 +14,19 @@ enum RoomChangeRolesScreenViewModelAction {
struct RoomChangeRolesScreenViewState: BindableState {
/// The screen's current mode (which role we are promoting/demoting users to/from.
let mode: RoomMemberDetails.Role
let mode: RoomRole
/// The current user's role.
var ownRole: RoomRole
/// All of the room's members who are currently owners or creators.
var owners: [RoomMemberDetails] = []
/// All of the room's members who are currently admins.
var administrators: [RoomMemberDetails]
var administrators: [RoomMemberDetails] = []
/// All of the room's members who are currently moderators.
var moderators: [RoomMemberDetails]
var moderators: [RoomMemberDetails] = []
/// All of the room's members who are currently neither an admin or moderator.
var users: [RoomMemberDetails]
var users: [RoomMemberDetails] = []
var bindings: RoomChangeRolesScreenViewStateBindings
var bindings = RoomChangeRolesScreenViewStateBindings()
/// The members selected for promotion to the current role.
var membersToPromote: Set<RoomMemberDetails> = []
@@ -35,9 +39,17 @@ struct RoomChangeRolesScreenViewState: BindableState {
/// The screen's title.
var title: String {
switch mode {
case .creator, .owner, .administrator:
// TODO: Handle the creator permissions change
L10n.screenRoomChangeRoleAdministratorsTitle
case .creator:
"" // The screen can't be configured with this role.
case .owner:
L10n.screenRoomChangeRoleOwnersTitle
case .administrator:
switch ownRole {
case .creator:
L10n.screenRoomChangeRoleAdministratorsOrOwnersTitle
default:
L10n.screenRoomChangeRoleAdministratorsTitle
}
case .moderator:
L10n.screenRoomChangeRoleModeratorsTitle
case .user:
@@ -45,6 +57,10 @@ struct RoomChangeRolesScreenViewState: BindableState {
}
}
var visibleOwners: [RoomMemberDetails] {
owners.filter { $0.matches(searchQuery: bindings.searchQuery) }
}
/// The visible admins in the screen (after searching).
var visibleAdministrators: [RoomMemberDetails] {
administrators.filter { $0.matches(searchQuery: bindings.searchQuery) }
@@ -62,7 +78,8 @@ struct RoomChangeRolesScreenViewState: BindableState {
/// All of the members who will gain/keep this screen's role after saving any changes.
var membersWithRole: [RoomMemberDetails] {
administrators.filter(isMemberSelected) + moderators.filter(isMemberSelected) + users.filter(isMemberSelected)
let members = owners + administrators + moderators + users
return members.filter(isMemberSelected)
}
/// Whether or not any changes have been made to the members.
@@ -78,7 +95,31 @@ struct RoomChangeRolesScreenViewState: BindableState {
/// Whether or not a specific member has this screen's role.
func isMemberSelected(_ member: RoomMemberDetails) -> Bool {
guard !membersToDemote.contains(member) else { return false }
return member.role == mode || membersToPromote.contains(member)
return member.role >= mode || membersToPromote.contains(member)
}
func isMemberDisabled(_ member: RoomMemberDetails) -> Bool {
member.role > maxDemotableRole
}
var maxDemotableRole: RoomRole {
switch mode {
case .owner:
return .owner
case .administrator:
switch ownRole {
case .owner:
return .administrator
case .creator:
return .owner
default:
return .moderator
}
case .moderator:
return .moderator
default:
return .user
}
}
}
@@ -91,6 +132,8 @@ struct RoomChangeRolesScreenViewStateBindings {
enum RoomChangeRolesScreenAlertType {
/// A warning that a particular promotion can't be undone.
case promotionWarning
/// A warning that ownership transfer is final when the room is left.
case transferOwnershipWarning
/// A confirmation that the user would like to discard any unsaved changes.
case discardChanges
/// The generic error message.
@@ -20,22 +20,19 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
actionsSubject.eraseToAnyPublisher()
}
init(mode: RoomMemberDetails.Role,
init(mode: RoomRole,
roomProxy: JoinedRoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) {
guard mode != .user else { fatalError("Invalid screen configuration: \(mode)") }
guard mode != .user || mode != .creator else { fatalError("Invalid screen configuration: \(mode)") }
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.analytics = analytics
super.init(initialViewState: RoomChangeRolesScreenViewState(mode: mode,
administrators: [],
moderators: [],
users: [],
bindings: .init()),
ownRole: roomProxy.membersPublisher.value.first { $0.userID == roomProxy.id }?.role ?? .administrator),
mediaProvider: mediaProvider)
roomProxy.membersPublisher
@@ -64,11 +61,16 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
case .demoteMember(let member):
demoteMember(member)
case .save:
if state.mode.isAdminOrHigher, !state.membersToPromote.isEmpty {
showPromotionWarning()
} else {
Task { await save() }
if !state.membersToPromote.isEmpty {
if state.mode == .administrator, state.ownRole == .administrator {
showPromotionWarning()
return
} else if state.mode == .owner {
showTransferOwnershipWarning()
return
}
}
Task { await save() }
case .cancel:
confirmDiscardChanges()
}
@@ -77,17 +79,23 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
// MARK: - Private
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
var owners = [RoomMemberDetails]()
var administrators = [RoomMemberDetails]()
var moderators = [RoomMemberDetails]()
var users = [RoomMemberDetails]()
for member in members.sorted() {
if member.userID == roomProxy.ownUserID {
state.ownRole = member.role
}
guard member.isActive else { continue }
let memberDetails = RoomMemberDetails(withProxy: member)
switch member.role {
// TODO: Create a owner list
case .administrator, .creator:
switch memberDetails.role {
case .creator, .owner:
owners.append(memberDetails)
case .administrator:
administrators.append(memberDetails)
case .moderator:
moderators.append(memberDetails)
@@ -96,6 +104,7 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
}
}
state.owners = owners
state.administrators = administrators
state.moderators = moderators
state.users = users
@@ -107,9 +116,9 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
} else if state.membersToDemote.contains(member) {
state.membersToDemote.remove(member)
state.lastPromotedMember = member
} else if member.role == state.mode {
} else if member.role >= state.mode, member.role <= state.maxDemotableRole {
state.membersToDemote.insert(member)
} else {
} else if member.role < state.mode {
state.membersToPromote.insert(member)
state.lastPromotedMember = member
}
@@ -133,6 +142,16 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private func showTransferOwnershipWarning() {
context.alertInfo = AlertInfo(id: .transferOwnershipWarning,
title: L10n.screenRoomChangeRoleConfirmChangeOwnersTitle,
message: L10n.screenRoomChangeRoleConfirmChangeOwnersDescription,
primaryButton: .init(title: L10n.actionContinue, role: .destructive) {
Task { await self.save() }
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private func save() async {
showSavingIndicator()
@@ -45,15 +45,18 @@ struct RoomChangeRolesScreen: View {
}
}
RoomChangeRolesScreenSection(members: context.viewState.visibleOwners,
role: .owner,
context: context)
RoomChangeRolesScreenSection(members: context.viewState.visibleAdministrators,
title: L10n.screenRoomChangeRoleSectionAdministrators,
isAdministratorsSection: true,
role: .administrator,
context: context)
RoomChangeRolesScreenSection(members: context.viewState.visibleModerators,
title: L10n.screenRoomChangeRoleSectionModerators,
role: .moderator,
context: context)
RoomChangeRolesScreenSection(members: context.viewState.visibleUsers,
title: L10n.screenRoomChangeRoleSectionUsers,
role: .user,
context: context)
}
}
@@ -65,10 +68,12 @@ struct RoomChangeRolesScreen: View {
ScrollViewReader { scrollView in
HStack(spacing: 16) {
ForEach(context.viewState.membersWithRole, id: \.id) { member in
RoomChangeRolesScreenSelectedItem(member: member, mediaProvider: context.mediaProvider) {
let dismissAction = context.viewState.isMemberDisabled(member) ? nil : {
context.send(viewAction: .demoteMember(member))
}
.frame(width: cellWidth)
RoomChangeRolesScreenSelectedItem(member: member, mediaProvider: context.mediaProvider,
dismissAction: dismissAction)
.frame(width: cellWidth)
}
}
.onChange(of: context.viewState.lastPromotedMember) { _, newValue in
@@ -104,10 +109,22 @@ struct RoomChangeRolesScreen: View {
// MARK: - Previews
struct RoomChangeRolesScreen_Previews: PreviewProvider, TestablePreview {
static let administratorViewModel = makeViewModel(mode: .administrator)
static let moderatorViewModel = makeViewModel(mode: .moderator)
static let ownerViewModel = makeViewModel(mode: .owner, ownRole: .creator)
static let administratorOrOwnerViewModel = makeViewModel(mode: .administrator, ownRole: .creator)
static let administratorViewModel = makeViewModel(mode: .administrator, ownRole: .administrator)
static let moderatorViewModel = makeViewModel(mode: .moderator, ownRole: .administrator)
static var previews: some View {
NavigationStack {
RoomChangeRolesScreen(context: ownerViewModel.context)
}
.previewDisplayName("Owners")
NavigationStack {
RoomChangeRolesScreen(context: administratorOrOwnerViewModel.context)
}
.previewDisplayName("Administrator or Owners")
NavigationStack {
RoomChangeRolesScreen(context: administratorViewModel.context)
}
@@ -119,11 +136,18 @@ struct RoomChangeRolesScreen_Previews: PreviewProvider, TestablePreview {
.previewDisplayName("Moderators")
}
static func makeViewModel(mode: RoomMemberDetails.Role) -> RoomChangeRolesScreenViewModel {
RoomChangeRolesScreenViewModel(mode: mode,
roomProxy: JoinedRoomProxyMock(.init(members: .allMembersAsAdmin)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
static func makeViewModel(mode: RoomRole, ownRole: RoomRole) -> RoomChangeRolesScreenViewModel {
let members: [RoomMemberProxyMock] = switch ownRole {
case .creator:
.allMembersAsCreator
default:
.allMembersAsAdminV2
}
return RoomChangeRolesScreenViewModel(mode: mode,
roomProxy: JoinedRoomProxyMock(.init(members: members)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
}
}
@@ -10,36 +10,46 @@ import SwiftUI
struct RoomChangeRolesScreenSection: View {
let members: [RoomMemberDetails]
let title: String
var isAdministratorsSection = false
let role: RoomRole
let context: RoomChangeRolesScreenViewModel.Context
var title: String {
switch role {
case .creator, .owner:
L10n.screenRoomRolesAndPermissionsOwners
case .administrator:
L10n.screenRoomChangeRoleSectionAdministrators
case .moderator:
L10n.screenRoomChangeRoleSectionModerators
case .user:
L10n.screenRoomChangeRoleSectionUsers
}
}
var body: some View {
if !members.isEmpty {
Section {
ForEach(members, id: \.id) { member in
RoomChangeRolesScreenRow(member: member,
mediaProvider: context.mediaProvider,
isSelected: isMemberSelected(member)) {
isSelected: context.viewState.isMemberSelected(member)) {
context.send(viewAction: .toggleMember(member))
}
.disabled(member.role.isAdminOrHigher)
.disabled(context.viewState.isMemberDisabled(member))
}
} header: {
Text(title)
.compoundListSectionHeader()
} footer: {
if isAdministratorsSection, context.viewState.mode == .moderator {
if role == .administrator, context.viewState.mode == .moderator {
Text(L10n.screenRoomChangeRoleModeratorsAdminSectionFooter)
.compoundListSectionFooter()
} else if role.isOwner, context.viewState.mode != .owner {
Text(L10n.screenRoomChangeRoleModeratorsOwnerSectionFooter)
.compoundListSectionFooter()
}
}
}
}
private func isMemberSelected(_ member: RoomMemberDetails) -> Bool {
// We always show administrators as selected, even on the moderators screen.
member.role.isAdminOrHigher || context.viewState.isMemberSelected(member)
}
}
@@ -10,9 +10,22 @@ import SwiftUI
struct RoomChangeRolesScreenSelectedItem: View {
let member: RoomMemberDetails
let mediaProvider: MediaProviderProtocol?
let dismissAction: () -> Void
let dismissAction: (() -> Void)?
var body: some View {
mainContent
.accessibilityActions {
if let dismissAction {
Button(L10n.actionDismiss) {
dismissAction()
}
}
}
}
// MARK: - Private
private var mainContent: some View {
VStack(spacing: 4) {
avatar
@@ -22,13 +35,8 @@ struct RoomChangeRolesScreenSelectedItem: View {
.lineLimit(1)
}
.accessibilityElement(children: .combine)
.accessibilityAction(named: L10n.actionDismiss) {
dismissAction()
}
}
// MARK: - Private
var avatar: some View {
LoadableAvatarImage(url: member.avatarURL,
name: member.name,
@@ -37,7 +45,7 @@ struct RoomChangeRolesScreenSelectedItem: View {
mediaProvider: mediaProvider)
.accessibilityHidden(true)
.overlay(alignment: .topTrailing) {
if member.role != .administrator {
if let dismissAction {
Button(action: dismissAction) {
Image(systemName: "xmark.circle.fill")
.resizable()
@@ -34,6 +34,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentKnockingRequestsListScreen
case presentSecurityAndPrivacyScreen
case presentReportRoomScreen
case transferOwnership
}
final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
@@ -94,6 +95,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRecipientDetails(userID: userID))
case .displayReportRoom:
actionsSubject.send(.presentReportRoomScreen)
case .transferOwnership:
actionsSubject.send(.transferOwnership)
}
}
.store(in: &cancellables)
@@ -27,6 +27,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
case displayKnockingRequests
case displaySecurityAndPrivacy
case displayReportRoom
case transferOwnership
}
// MARK: View
@@ -288,6 +289,8 @@ enum RoomDetailsScreenErrorType: Hashable {
case alert
/// Leaving room has failed..
case unknown
/// Last owner
case lastOwner
}
enum RoomDetailsScreenPinnedEventsActionState {
@@ -119,13 +119,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
case .processTapInvite:
actionsSubject.send(.requestInvitePeoplePresentation)
case .processTapLeave:
guard state.joinedMembersCount > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomProxy.id, isDM: roomProxy.isDirectOneToOneRoom, state: .empty)
return
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomProxy.id,
isDM: roomProxy.isDirectOneToOneRoom,
state: roomProxy.infoPublisher.value.isPrivate ?? true ? .private : .public)
processTapToLeave()
case .confirmLeave:
Task { await leaveRoom() }
case .processTapIgnore:
@@ -177,6 +171,38 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
// MARK: - Private
private func processTapToLeave() {
guard state.joinedMembersCount > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomProxy.id,
isDM: roomProxy.isDirectOneToOneRoom,
state: roomProxy.infoPublisher.value.isPrivate ?? true ? .empty : .public)
return
}
if !roomProxy.isDirectOneToOneRoom, state.accountOwner?.role.isOwner == true {
var isLastOwner = true
for member in roomProxy.membersPublisher.value where member.userID != roomProxy.ownUserID {
if member.role.isOwner {
isLastOwner = false
break
}
}
if isLastOwner {
state.bindings.alertInfo = .init(id: .lastOwner,
title: L10n.leaveRoomAlertSelectNewOwnerTitle,
message: L10n.leaveRoomAlertSelectNewOwnerSubtitle,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.leaveRoomAlertSelectNewOwnerAction, role: .destructive, action: { [weak self] in self?.actionsSubject.send(.transferOwnership) }))
return
}
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomID: roomProxy.id,
isDM: roomProxy.isDirectOneToOneRoom,
state: roomProxy.infoPublisher.value.isPrivate ?? true ? .private : .public)
}
private func setupRoomSubscription() {
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
@@ -237,11 +263,6 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
}
private func fetchMembersIfNeeded() async {
// We need to fetch members just in 1-to-1 chat to get the member object for the other person
guard roomProxy.isDirectOneToOneRoom else {
return
}
roomProxy.membersPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self, ownUserID = roomProxy.ownUserID] members in
@@ -251,6 +272,10 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
self.state.accountOwner = .init(withProxy: accountOwner)
}
guard roomProxy.isDirectOneToOneRoom else {
return
}
if let dmRecipient = members.first(where: { $0.userID != ownUserID }) {
self.state.dmRecipientInfo = .init(member: .init(withProxy: dmRecipient))
@@ -17,12 +17,16 @@ enum RoomRolesAndPermissionsScreenViewModelAction {
}
struct RoomRolesAndPermissionsScreenViewState: BindableState {
var ownRole: RoomRole
var administratorsAndOwnersCount: Int?
/// The number of administrators in the room.
var administratorCount: Int?
/// The number of moderators in the room.
var moderatorCount: Int?
/// The permissions of the room when loaded.
var permissions: RoomPermissions?
var bindings = RoomRolesAndPermissionsScreenViewStateBindings()
}
@@ -46,7 +50,7 @@ enum RoomRolesAndPermissionsScreenViewAction {
case reset
}
enum RoomRolesAndPermissionsScreenRole {
enum RoomRolesAndPermissionsScreenRole: Hashable {
case administrators
case moderators
}
@@ -14,6 +14,7 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
private var ownUser: RoomMemberDetails?
private var actionsSubject: PassthroughSubject<RoomRolesAndPermissionsScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomRolesAndPermissionsScreenViewModelAction, Never> {
@@ -24,7 +25,8 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.analytics = analytics
super.init(initialViewState: RoomRolesAndPermissionsScreenViewState(permissions: initialPermissions))
super.init(initialViewState: RoomRolesAndPermissionsScreenViewState(ownRole: roomProxy.membersPublisher.value.first(where: { $0.userID == roomProxy.ownUserID })?.role ?? .administrator,
permissions: initialPermissions))
// Automatically update the admin/moderator counts.
roomProxy.membersPublisher
@@ -80,16 +82,19 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM
secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { })
}
}
// MARK: - Members
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
// TODO: Will probably be changed when we implement the owners list
state.administratorCount = members.filter { $0.role.isAdminOrHigher && $0.isActive }.count
state.administratorsAndOwnersCount = members.filter { $0.role.isAdminOrHigher && $0.isActive }.count
state.administratorCount = members.filter { $0.role == .administrator && $0.isActive }.count
state.moderatorCount = members.filter { $0.role == .moderator && $0.isActive }.count
if let ownUser = members.first(where: { $0.userID == roomProxy.ownUserID }) {
state.ownRole = ownUser.role
}
}
private func updateOwnRole(_ role: RoomMemberDetails.Role) async {
private func updateOwnRole(_ role: RoomRole) async {
showSavingIndicator()
// A task we can await until the room's info gets modified with the new power levels.
@@ -142,7 +147,7 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM
hideSavingIndicator()
}
// MARK: Loading indicator
private static let savingIndicatorID = "RolesAndPermissionsSaving"
@@ -26,13 +26,23 @@ struct RoomRolesAndPermissionsScreen: View {
private var rolesSection: some View {
Section {
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsAdmins,
icon: \.admin),
details: administratorDetails,
kind: .navigationLink {
context.send(viewAction: .editRoles(.administrators))
})
.accessibilityIdentifier(A11yIdentifiers.roomRolesAndPermissionsScreen.administrators)
if context.viewState.ownRole == .creator {
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsAdminsAndOwners,
icon: \.admin),
details: administratorOrOwnersDetails,
kind: .navigationLink {
context.send(viewAction: .editRoles(.administrators))
})
.accessibilityIdentifier(A11yIdentifiers.roomRolesAndPermissionsScreen.administrators)
} else {
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsAdmins,
icon: \.admin),
details: administratorDetails,
kind: .navigationLink {
context.send(viewAction: .editRoles(.administrators))
})
.accessibilityIdentifier(A11yIdentifiers.roomRolesAndPermissionsScreen.administrators)
}
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsModerators,
icon: \.chatProblem),
@@ -42,17 +52,27 @@ struct RoomRolesAndPermissionsScreen: View {
})
.accessibilityIdentifier(A11yIdentifiers.roomRolesAndPermissionsScreen.moderators)
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsChangeMyRole,
icon: \.edit),
kind: .button {
context.send(viewAction: .editOwnUserRole)
})
if context.viewState.ownRole != .creator {
ListRow(label: .default(title: L10n.screenRoomRolesAndPermissionsChangeMyRole,
icon: \.edit),
kind: .button {
context.send(viewAction: .editOwnUserRole)
})
}
} header: {
Text(L10n.screenRoomRolesAndPermissionsRolesHeader)
.compoundListSectionHeader()
}
}
private var administratorOrOwnersDetails: ListRowDetails<Image> {
if let administratorCount = context.viewState.administratorsAndOwnersCount {
.title("\(administratorCount)")
} else {
.isWaiting(true)
}
}
private var administratorDetails: ListRowDetails<Image> {
if let administratorCount = context.viewState.administratorCount {
.title("\(administratorCount)")
@@ -121,9 +141,20 @@ struct RoomRolesAndPermissionsScreen_Previews: PreviewProvider, TestablePreview
roomProxy: JoinedRoomProxyMock(.init(members: .allMembersAsAdmin)),
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
static let creatorViewModel = RoomRolesAndPermissionsScreenViewModel(initialPermissions: RoomPermissions(powerLevels: .mock),
roomProxy: JoinedRoomProxyMock(.init(members: .allMembersAsCreator)),
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomRolesAndPermissionsScreen(context: viewModel.context)
}
.previewDisplayName("Admin")
NavigationStack {
RoomRolesAndPermissionsScreen(context: creatorViewModel.context)
}
.previewDisplayName("Creator")
}
}
@@ -211,7 +211,7 @@ extension AnalyticsService {
}
/// Track a room moderation action.
func trackRoomModeration(action: AnalyticsEvent.RoomModeration.Action, role: RoomMemberDetails.Role?) {
func trackRoomModeration(action: AnalyticsEvent.RoomModeration.Action, role: RoomRole?) {
let role = role.map(AnalyticsEvent.RoomModeration.Role.init)
capture(event: AnalyticsEvent.RoomModeration(action: action, role: role))
}
@@ -8,7 +8,7 @@
import AnalyticsEvents
extension AnalyticsEvent.RoomModeration.Role {
init(role: RoomMemberDetails.Role) {
init(role: RoomRole) {
switch role {
case .administrator, .creator, .owner:
// This probably needs to be updates
@@ -18,21 +18,8 @@ struct RoomMemberDetails: Identifiable, Hashable {
var isIgnored: Bool
var isBanned: Bool
var isActive: Bool
enum Role {
/// Creator of the room, PL infinite
case creator
/// Same power of an admin, but they can also upgrade the room, PL 150 onwards
case owner
/// Able to edit room settings and perform any action aside from room upgrading PL 100...149
case administrator
/// Able to perform room moderation actions PL 50...99
case moderator
/// Default role PL 0...49
case user
}
let role: Role
let role: RoomRole
let powerLevel: RoomPowerLevel
func matches(searchQuery: String) -> Bool {
@@ -51,47 +38,7 @@ extension RoomMemberDetails {
isInvited = proxy.membership == .invite
isIgnored = proxy.isIgnored
isBanned = proxy.membership == .ban
role = .init(proxy.role, powerLevel: proxy.powerLevel)
role = proxy.role
powerLevel = proxy.powerLevel
}
}
extension RoomMemberDetails.Role {
init(_ role: RoomMemberRole, powerLevel: RoomPowerLevel) {
switch role {
case .creator:
self = .creator
case .administrator:
switch powerLevel {
case .value(let value):
self = value >= 150 ? .owner : .administrator
default:
fatalError("Impossible")
}
case .moderator:
self = .moderator
case .user:
self = .user
}
}
var isAdminOrHigher: Bool {
switch self {
case .administrator, .creator, .owner:
return true
case .moderator, .user:
return false
}
}
}
extension RoomMemberRole {
var isAdminOrHigher: Bool {
switch self {
case .administrator, .creator:
return true
case .moderator, .user:
return false
}
}
}
@@ -36,6 +36,4 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
var isIgnored: Bool { member.isIgnored }
var powerLevel: RoomPowerLevel { .init(rustPowerLevel: member.powerLevel) }
var role: RoomMemberRole { member.suggestedRoleForPowerLevel }
}
@@ -24,8 +24,6 @@ protocol RoomMemberProxyProtocol: AnyObject {
var isIgnored: Bool { get }
var powerLevel: RoomPowerLevel { get }
var role: RoomMemberRole { get }
}
extension RoomMemberProxyProtocol {
@@ -58,3 +56,7 @@ extension [RoomMemberProxyProtocol] {
}
}
}
extension RoomMemberProxyProtocol {
var role: RoomRole { .init(powerLevel: powerLevel) }
}
@@ -9,15 +9,15 @@ import Foundation
import MatrixRustSDK
struct RoomPermissionsSetting: Identifiable {
var id: KeyPath<RoomPermissions, RoomMemberDetails.Role> { keyPath }
var id: KeyPath<RoomPermissions, RoomRole> { keyPath }
/// The title of this setting.
let title: String
/// The selected role of this setting.
var value: RoomMemberDetails.Role
var value: RoomRole
/// All of the available roles that this setting can be configured with.
var allValues: [(title: String, tag: RoomMemberDetails.Role)] {
var allValues: [(title: String, tag: RoomRole)] {
[
(title: L10n.screenRoomChangePermissionsAdministrators, tag: .administrator),
(title: L10n.screenRoomChangePermissionsModerators, tag: .moderator),
@@ -26,7 +26,7 @@ struct RoomPermissionsSetting: Identifiable {
}
/// The `RoomPermissions` property that this setting is for.
let keyPath: KeyPath<RoomPermissions, RoomMemberDetails.Role>
let keyPath: KeyPath<RoomPermissions, RoomRole>
/// The `RoomPowerLevelChanges` property that this setting is saved into.
var rustKeyPath: WritableKeyPath<RoomPowerLevelChanges, Int64?> {
switch keyPath {
@@ -47,99 +47,39 @@ struct RoomPermissionsSetting: Identifiable {
struct RoomPermissions {
/// The level required to ban a user.
var ban: RoomMemberDetails.Role
var ban: RoomRole
/// The level required to invite a user.
var invite: RoomMemberDetails.Role
var invite: RoomRole
/// The level required to kick a user.
var kick: RoomMemberDetails.Role
var kick: RoomRole
/// The level required to redact an event.
var redact: RoomMemberDetails.Role
var redact: RoomRole
/// The default level required to send message events.
var eventsDefault: RoomMemberDetails.Role
var eventsDefault: RoomRole
/// The default level required to send state events.
var stateDefault: RoomMemberDetails.Role
var stateDefault: RoomRole
/// The default power level for every user in the room.
var usersDefault: RoomMemberDetails.Role
var usersDefault: RoomRole
/// The level required to change the room's name.
var roomName: RoomMemberDetails.Role
var roomName: RoomRole
/// The level required to change the room's avatar.
var roomAvatar: RoomMemberDetails.Role
var roomAvatar: RoomRole
/// The level required to change the room's topic.
var roomTopic: RoomMemberDetails.Role
var roomTopic: RoomRole
}
extension RoomPermissions {
/// Create permissions from the room's power levels.
init(powerLevels: RoomPowerLevelsValues) {
ban = RoomMemberDetails.Role(powerLevelValue: powerLevels.ban)
invite = RoomMemberDetails.Role(powerLevelValue: powerLevels.invite)
kick = RoomMemberDetails.Role(powerLevelValue: powerLevels.kick)
redact = RoomMemberDetails.Role(powerLevelValue: powerLevels.redact)
eventsDefault = RoomMemberDetails.Role(powerLevelValue: powerLevels.eventsDefault)
stateDefault = RoomMemberDetails.Role(powerLevelValue: powerLevels.stateDefault)
usersDefault = RoomMemberDetails.Role(powerLevelValue: powerLevels.usersDefault)
roomName = RoomMemberDetails.Role(powerLevelValue: powerLevels.roomName)
roomAvatar = RoomMemberDetails.Role(powerLevelValue: powerLevels.roomAvatar)
roomTopic = RoomMemberDetails.Role(powerLevelValue: powerLevels.roomTopic)
}
}
extension RoomMemberDetails.Role {
init(powerLevelValue: Int64) {
// Also this is not great, and should be handled by a `suggestedRoleForPowerLevelValue` function from the SDK
guard powerLevelValue < 150 else {
self = .owner
return
}
do {
switch try suggestedRoleForPowerLevel(powerLevel: .value(value: powerLevelValue)) {
case .administrator:
self = .administrator
case .creator:
fatalError("Impossible")
case .moderator:
self = .moderator
case .user:
self = .user
}
} catch {
MXLog.error("Falied to convert power level value to role: \(error)")
self = .user
}
}
var rustRole: RoomMemberRole {
switch self {
case .creator:
.creator
case .administrator, .owner:
.administrator
case .moderator:
.moderator
case .user:
.user
}
}
/// To be used when setting the power level of a user to get the suggested equivalent power level value for that specific role
/// NOTE: Do not use for comparison, use the true power level instead.
var powerLevelValue: Int64 {
guard self != .owner else {
// Would be better if the SDK would return this, maybe a `suggestedPowerLevelValueForRole` function would solve the problem
return 150
}
do {
switch try suggestedPowerLevelForRole(role: rustRole) {
case .infinite:
fatalError("Impossible")
case .value(let value):
return value
}
} catch {
MXLog.error("Falied to convert role to power level value: \(error)")
return 0
}
ban = RoomRole(powerLevelValue: powerLevels.ban)
invite = RoomRole(powerLevelValue: powerLevels.invite)
kick = RoomRole(powerLevelValue: powerLevels.kick)
redact = RoomRole(powerLevelValue: powerLevels.redact)
eventsDefault = RoomRole(powerLevelValue: powerLevels.eventsDefault)
stateDefault = RoomRole(powerLevelValue: powerLevels.stateDefault)
usersDefault = RoomRole(powerLevelValue: powerLevels.usersDefault)
roomName = RoomRole(powerLevelValue: powerLevels.roomName)
roomAvatar = RoomRole(powerLevelValue: powerLevels.roomAvatar)
roomTopic = RoomRole(powerLevelValue: powerLevels.roomTopic)
}
}
@@ -0,0 +1,129 @@
//
// 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 MatrixRustSDK
enum RoomRole: Comparable {
/// Default role PL 0...49
case user
/// Able to perform room moderation actions PL 50...99
case moderator
/// Able to edit room settings and perform any action aside from room upgrading PL 100...149
case administrator
/// Same power of an admin, but they can also upgrade the room, PL 150 onwards
case owner
/// Creator of the room, PL infinite
case creator
}
extension RoomRole {
init(powerLevel: RoomPowerLevel) {
do {
let role = try suggestedRoleForPowerLevel(powerLevel: powerLevel.rustPowerLevel)
self.init(role, powerLevel: powerLevel)
} catch {
MXLog.error("Failled to get suggested role for power level \(powerLevel): \(error)")
self = .user
}
}
init(_ role: RoomMemberRole, powerLevel: RoomPowerLevel) {
switch role {
case .creator:
self = .creator
case .administrator:
switch powerLevel {
case .value(let value):
self = value >= 150 ? .owner : .administrator
default:
fatalError("Impossible")
}
case .moderator:
self = .moderator
case .user:
self = .user
}
}
var isAdminOrHigher: Bool {
switch self {
case .administrator, .creator, .owner:
return true
case .moderator, .user:
return false
}
}
var isOwner: Bool {
switch self {
case .creator, .owner:
return true
case .administrator, .moderator, .user:
return false
}
}
}
extension RoomRole {
init(powerLevelValue: Int64) {
// Also this is not great, and should be handled by a `suggestedRoleForPowerLevelValue` function from the SDK
guard powerLevelValue < 150 else {
self = .owner
return
}
do {
switch try suggestedRoleForPowerLevel(powerLevel: .value(value: powerLevelValue)) {
case .administrator:
self = .administrator
case .creator:
fatalError("Impossible")
case .moderator:
self = .moderator
case .user:
self = .user
}
} catch {
MXLog.error("Falied to convert power level value to role: \(error)")
self = .user
}
}
var rustRole: RoomMemberRole {
switch self {
case .creator:
.creator
case .administrator, .owner:
.administrator
case .moderator:
.moderator
case .user:
.user
}
}
/// To be used when setting the power level of a user to get the suggested equivalent power level value for that specific role
/// NOTE: Do not use for comparison, use the true power level instead.
var powerLevelValue: Int64 {
guard self != .owner else {
// Would be better if the SDK would return this, maybe a `suggestedPowerLevelValueForRole` function would solve the problem
return 150
}
do {
switch try suggestedPowerLevelForRole(role: rustRole) {
case .infinite:
fatalError("Impossible")
case .value(let value):
return value
}
} catch {
MXLog.error("Falied to convert role to power level value: \(error)")
return 0
}
}
}