Merge pull request #5950 from element-hq/feature/fga/iterate_permissions_screen

Changes : iterate again on permissions
This commit is contained in:
ganfra
2025-12-22 18:41:38 +01:00
committed by GitHub
36 changed files with 243 additions and 151 deletions

View File

@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -20,9 +21,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
@@ -52,7 +55,6 @@ class ChangeRoomPermissionsPresenter(
)
RoomPermissionsSection.ManageSpace -> persistentListOf(
RoomPermissionType.SPACE_MANAGE_ROOMS,
RoomPermissionType.CHANGE_SETTINGS,
)
}
@@ -90,6 +92,10 @@ class ChangeRoomPermissionsPresenter(
derivedStateOf { initialPermissions != currentPermissions }
}
val ownPowerLevel by remember {
room.roomInfoFlow.mapState { it.powerLevelOf(room.sessionId) }
}.collectAsState()
fun handleEvent(event: ChangeRoomPermissionsEvent) {
when (event) {
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
@@ -108,7 +114,6 @@ class ChangeRoomPermissionsPresenter(
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel)
RoomPermissionType.CHANGE_SETTINGS -> currentPermissions?.copy(stateDefault = powerLevel)
}
}
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
@@ -125,6 +130,7 @@ class ChangeRoomPermissionsPresenter(
}
}
return ChangeRoomPermissionsState(
ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection,
hasChanges = hasChanges,

View File

@@ -18,35 +18,55 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
data class ChangeRoomPermissionsState(
private val ownPowerLevel: Long,
val currentPermissions: RoomPowerLevelsValues?,
val itemsBySection: ImmutableMap<RoomPermissionsSection, ImmutableList<RoomPermissionType>>,
val hasChanges: Boolean,
val saveAction: AsyncAction<Boolean>,
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
) {
private val ownRole = RoomMember.Role.forPowerLevel(ownPowerLevel)
// Roles that the user can select based on their own role
val selectableRoles: ImmutableList<SelectableRole> = when (ownRole) {
is RoomMember.Role.Owner,
RoomMember.Role.Admin -> persistentListOf(SelectableRole.Admin, SelectableRole.Moderator, SelectableRole.Everyone)
RoomMember.Role.Moderator -> persistentListOf(SelectableRole.Moderator, SelectableRole.Everyone)
RoomMember.Role.User -> persistentListOf(SelectableRole.Everyone)
}
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
if (currentPermissions == null) return null
val role = when (type) {
RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban)
RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite)
RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick)
RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.eventsDefault)
RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents)
RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName)
RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar)
RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic)
RoomPermissionType.SPACE_MANAGE_ROOMS -> RoomMember.Role.forPowerLevel(currentPermissions.spaceChild)
RoomPermissionType.CHANGE_SETTINGS -> RoomMember.Role.forPowerLevel(currentPermissions.stateDefault)
}
return when (role) {
val powerLevel = currentPowerLevelForType(type = type) ?: return null
return when (RoomMember.Role.forPowerLevel(powerLevel)) {
is RoomMember.Role.Owner,
RoomMember.Role.Admin -> SelectableRole.Admin
RoomMember.Role.Moderator -> SelectableRole.Moderator
RoomMember.Role.User -> SelectableRole.Everyone
}
}
fun canChangePermission(type: RoomPermissionType): Boolean {
val currentPowerLevel = currentPowerLevelForType(type) ?: return false
return ownPowerLevel >= currentPowerLevel
}
private fun currentPowerLevelForType(type: RoomPermissionType): Long? {
if (currentPermissions == null) return null
return when (type) {
RoomPermissionType.BAN -> currentPermissions.ban
RoomPermissionType.INVITE -> currentPermissions.invite
RoomPermissionType.KICK -> currentPermissions.kick
RoomPermissionType.SEND_EVENTS -> currentPermissions.eventsDefault
RoomPermissionType.REDACT_EVENTS -> currentPermissions.redactEvents
RoomPermissionType.ROOM_NAME -> currentPermissions.roomName
RoomPermissionType.ROOM_AVATAR -> currentPermissions.roomAvatar
RoomPermissionType.ROOM_TOPIC -> currentPermissions.roomTopic
RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions.spaceChild
}
}
}
enum class RoomPermissionsSection {
@@ -84,5 +104,4 @@ enum class RoomPermissionType {
ROOM_AVATAR,
ROOM_TOPIC,
SPACE_MANAGE_ROOMS,
CHANGE_SETTINGS,
}

View File

@@ -19,6 +19,7 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPe
override val values: Sequence<ChangeRoomPermissionsState>
get() = sequenceOf(
aChangeRoomPermissionsState(),
aChangeRoomPermissionsState(ownPowerLevel = RoomMember.Role.Moderator.powerLevel),
aChangeRoomPermissionsState(hasChanges = true),
aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
aChangeRoomPermissionsState(
@@ -31,12 +32,14 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPe
}
internal fun aChangeRoomPermissionsState(
ownPowerLevel: Long = RoomMember.Role.Admin.powerLevel,
currentPermissions: RoomPowerLevelsValues = previewPermissions(),
itemsBySection: Map<RoomPermissionsSection, ImmutableList<RoomPermissionType>> = ChangeRoomPermissionsPresenter.buildItems(false),
hasChanges: Boolean = false,
saveAction: AsyncAction<Boolean> = AsyncAction.Uninitialized,
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
) = ChangeRoomPermissionsState(
ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection.toImmutableMap(),
hasChanges = hasChanges,

View File

@@ -30,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,7 +73,8 @@ fun ChangeRoomPermissionsView(
PreferenceDropdown(
title = titleForType(permissionType),
selectedOption = state.selectedRoleForType(permissionType),
options = SelectableRole.entries.toImmutableList(),
options = state.selectableRoles,
enabled = state.canChangePermission(permissionType),
onSelectOption = { role ->
state.eventSink(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
@@ -127,7 +127,6 @@ private fun titleForType(type: RoomPermissionType): String = when (type) {
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms)
RoomPermissionType.CHANGE_SETTINGS -> stringResource(R.string.screen_room_change_permissions_change_settings)
}
@PreviewsDayNight

View File

@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService
@@ -124,9 +125,10 @@ class ChangeRolesPresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean {
val currentUserRole = roomInfo.roleOf(room.sessionId)
val otherUserRole = roomInfo.roleOf(userId)
return currentUserRole.powerLevel > otherUserRole.powerLevel
val currentUserPowerLevel = roomInfo.powerLevelOf(room.sessionId)
val otherUserPowerLevel = roomInfo.powerLevelOf(userId)
return currentUserPowerLevel > otherUserPowerLevel &&
currentUserPowerLevel >= role.powerLevel
}
fun handleEvent(event: ChangeRolesEvent) {

View File

@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -49,7 +50,16 @@ class RolesAndPermissionsPresenter(
room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner }
}.collectAsState(null)
val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
val availableDemoteActions by remember {
derivedStateOf {
val currentRole = roomInfo.roleOf(room.sessionId)
when (currentRole) {
is RoomMember.Role.Admin -> persistentListOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember)
is RoomMember.Role.Moderator -> persistentListOf(SelfDemoteAction.ToMember)
else -> persistentListOf()
}
}
}
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val resetPermissionsAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@@ -78,7 +88,7 @@ class RolesAndPermissionsPresenter(
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
adminCount = adminCount,
moderatorCount = moderatorCount,
canDemoteSelf = canDemoteSelf.value,
availableSelfDemoteActions = availableDemoteActions,
changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value,
eventSink = ::handleEvent,

View File

@@ -8,14 +8,24 @@
package io.element.android.features.rolesandpermissions.impl.root
import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RolesAndPermissionsState(
val roomSupportsOwnerRole: Boolean,
val adminCount: Int?,
val moderatorCount: Int?,
val canDemoteSelf: Boolean,
val availableSelfDemoteActions: ImmutableList<SelfDemoteAction>,
val changeOwnRoleAction: AsyncAction<Unit>,
val resetPermissionsAction: AsyncAction<Unit>,
val eventSink: (RolesAndPermissionsEvents) -> Unit,
)
) {
val canSelfDemote = availableSelfDemoteActions.isNotEmpty()
}
enum class SelfDemoteAction(val role: RoomMember.Role, val titleRes: Int) {
ToModerator(RoomMember.Role.Moderator, R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator),
ToMember(RoomMember.Role.User, R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
}

View File

@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.collections.immutable.toImmutableList
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
override val values: Sequence<RolesAndPermissionsState>
@@ -46,7 +47,7 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermis
moderatorCount = 2,
resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")),
),
aRolesAndPermissionsState(canDemoteSelf = false),
aRolesAndPermissionsState(availableSelfDemoteActions = emptyList()),
)
}
@@ -54,14 +55,14 @@ internal fun aRolesAndPermissionsState(
roomSupportsOwners: Boolean = true,
adminCount: Int = 0,
moderatorCount: Int = 0,
canDemoteSelf: Boolean = true,
availableSelfDemoteActions: List<SelfDemoteAction> = listOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember),
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
resetPermissionsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
roomSupportsOwnerRole = roomSupportsOwners,
adminCount = adminCount,
canDemoteSelf = canDemoteSelf,
availableSelfDemoteActions = availableSelfDemoteActions.toImmutableList(),
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
resetPermissionsAction = resetPermissionsAction,

View File

@@ -39,8 +39,8 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RolesAndPermissionsView(
@@ -76,7 +76,7 @@ fun RolesAndPermissionsView(
},
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
)
if (state.canDemoteSelf) {
if (state.canSelfDemote) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
@@ -117,6 +117,7 @@ fun RolesAndPermissionsView(
when (state.changeOwnRoleAction) {
is AsyncAction.Confirming -> {
ChangeOwnRoleBottomSheet(
availableDemoteActions = state.availableSelfDemoteActions,
eventSink = state.eventSink,
)
}
@@ -136,6 +137,7 @@ fun RolesAndPermissionsView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChangeOwnRoleBottomSheet(
availableDemoteActions: ImmutableList<SelfDemoteAction>,
eventSink: (RolesAndPermissionsEvents) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
@@ -164,24 +166,17 @@ private fun ChangeOwnRoleBottomSheet(
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
}
},
style = ListItemStyle.Destructive,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
}
},
style = ListItemStyle.Destructive,
)
for (demoteAction in availableDemoteActions) {
ListItem(
headlineContent = { Text(stringResource(demoteAction.titleRes)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role))
}
},
style = ListItemStyle.Destructive,
)
}
ListItem(
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
onClick = ::dismiss,

View File

@@ -16,13 +16,18 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -70,6 +75,28 @@ class ChangeRoomPermissionsPresenterTest {
}
}
@Test
fun `present - check canChangePermissions and selectableOptions for moderator`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
initialRoomInfo = initialRoomInfo(role = Moderator),
powerLevelsResult = { Result.success(defaultPermissions()) }
),
)
val presenter = createChangeRoomPermissionsPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.selectableRoles).containsExactly(SelectableRole.Moderator, SelectableRole.Everyone)
for (sectionItems in state.itemsBySection.values) {
for (permissionType in sectionItems) {
assertThat(state.canChangePermission(permissionType)).isTrue()
}
}
}
}
@Test
fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest {
val presenter = createChangeRoomPermissionsPresenter()
@@ -266,7 +293,10 @@ class ChangeRoomPermissionsPresenterTest {
private fun createChangeRoomPermissionsPresenter(
room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }),
baseRoom = FakeBaseRoom(
initialRoomInfo = initialRoomInfo(),
powerLevelsResult = { Result.success(defaultPermissions()) }
),
),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = ChangeRoomPermissionsPresenter(
@@ -274,6 +304,13 @@ class ChangeRoomPermissionsPresenterTest {
analyticsService = analyticsService,
)
private fun initialRoomInfo(role: RoomMember.Role = Admin) = aRoomInfo(
roomPowerLevels = RoomPowerLevels(
values = defaultPermissions(),
users = persistentMapOf(A_SESSION_ID to role.powerLevel),
)
)
private fun defaultPermissions() = defaultRoomPowerLevelValues()
private suspend fun TurbineTestContext<ChangeRoomPermissionsState>.awaitUpdatedItem(): ChangeRoomPermissionsState {

View File

@@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
) {
val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
val showBannedSection: Boolean = moderationState.permissions.hasAny && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection {

View File

@@ -13,6 +13,8 @@ data class RoomMemberModerationPermissions(
val canKick: Boolean,
val canBan: Boolean,
) {
val hasAny = canKick || canBan
companion object {
val DEFAULT = RoomMemberModerationPermissions(
canKick = false,

View File

@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -35,7 +36,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState
import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -56,11 +57,14 @@ class RoomMemberModerationPresenter(
@Composable
override fun present(): RoomMemberModerationState {
val coroutineScope = rememberCoroutineScope()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms ->
perms.roomMemberModerationPermissions()
}
val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value)
val currentUserPowerLevel by remember {
room.roomInfoFlow.mapState { info ->
info.powerLevelOf(room.sessionId)
}
}.collectAsState()
val kickUserAsyncAction =
remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
@@ -83,7 +87,7 @@ class RoomMemberModerationPresenter(
moderationActions.value = computeModerationActions(
member = member,
permissions = permissions,
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
currentUserPowerLevel = currentUserPowerLevel,
)
}
is RoomMemberModerationEvents.ProcessAction -> {
@@ -148,26 +152,26 @@ class RoomMemberModerationPresenter(
private fun computeModerationActions(
member: RoomMember?,
permissions: RoomMemberModerationPermissions,
currentUserMemberPowerLevel: Long,
currentUserPowerLevel: Long,
): ImmutableList<ModerationActionState> {
return buildList {
add(ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true))
// Assume the member is a regular user when it's unknown
val targetMemberPowerLevel = member?.powerLevel ?: 0
val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel
val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel
// Assume the member is joined when it's unknown
val membership = member?.membership ?: RoomMembershipState.JOIN
if (permissions.canKick) {
val isKickEnabled = canModerateThisUser && membership.isActive()
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled))
}
if (permissions.canBan) {
// Unban requires kick permission instead of a dedicated unban permission
if (membership == RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
} else {
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
} else if (membership != RoomMembershipState.LEAVE) {
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = canModerateThisUser))
}
}
if (permissions.canBan && membership != RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
}
}.toImmutableList()
}

View File

@@ -21,11 +21,14 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -33,6 +36,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -161,7 +165,6 @@ class RoomMemberModerationPresenterTest {
assertThat(updatedState.selectedUser).isEqualTo(targetUser)
assertThat(updatedState.actions).containsExactly(
ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true),
ModerationActionState(action = ModerationAction.KickUser, isEnabled = false),
ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true),
)
}
@@ -223,9 +226,11 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom()
room.baseRoom.givenUpdateMembersResult {
// Simulate the member list being updated
room.givenRoomMembersState(RoomMembersState.Ready(
persistentListOf(aRoomMember())
))
room.givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(aRoomMember())
)
)
}
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -251,9 +256,11 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom()
room.baseRoom.givenUpdateMembersResult {
// Simulate the member list being updated
room.givenRoomMembersState(RoomMembersState.Ready(
persistentListOf(aRoomMember())
))
room.givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(aRoomMember())
)
)
}
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -279,9 +286,11 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom()
room.baseRoom.givenUpdateMembersResult {
// Simulate the member list being updated
room.givenRoomMembersState(RoomMembersState.Ready(
persistentListOf(aRoomMember())
))
room.givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(aRoomMember())
)
)
}
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -361,7 +370,13 @@ class RoomMemberModerationPresenterTest {
canKick = canKick
),
userRoleResult = { Result.success(myUserRole) },
updateMembersResult = { Result.success(Unit) }
updateMembersResult = { Result.success(Unit) },
initialRoomInfo = aRoomInfo(
roomPowerLevels = RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
users = persistentMapOf(A_USER_ID to myUserRole.powerLevel)
)
)
),
).apply {
val roomMembers = listOfNotNull(targetRoomMember).toImmutableList()

View File

@@ -70,7 +70,7 @@ private fun PreferenceBlockUser(
isLoading: Boolean,
eventSink: (UserProfileEvents) -> Unit,
) {
val loadingCurrentValue = @Composable {
val loadingCurrentValue = @Composable { _: Boolean ->
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()

View File

@@ -85,7 +85,7 @@ sealed interface ListItemContent {
data class Text(val text: String) : ListItemContent
/** Displays any custom content. */
data class Custom(val content: @Composable () -> Unit) : ListItemContent
data class Custom(val content: @Composable (enabled: Boolean) -> Unit) : ListItemContent
/** Displays a badge. */
data object Badge : ListItemContent
@@ -131,7 +131,7 @@ sealed interface ListItemContent {
is Counter -> {
CounterAtom(count = count)
}
is Custom -> content()
is Custom -> content(isItemEnabled)
}
}
}

View File

@@ -43,7 +43,6 @@ fun PreferenceCheckbox(
leadingContent = preferenceIcon(
icon = icon,
iconResourceId = iconResourceId,
enabled = enabled,
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
),
headlineContent = {

View File

@@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.DropdownMenuIt
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.toIconSecondaryEnabledColor
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -64,7 +64,6 @@ fun <T : DropdownOption> PreferenceDropdown(
leadingContent = preferenceIcon(
icon = icon,
iconResourceId = iconResourceId,
enabled = enabled,
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
),
headlineContent = {
@@ -72,7 +71,6 @@ fun <T : DropdownOption> PreferenceDropdown(
style = ElementTheme.typography.fontBodyLgRegular,
modifier = Modifier.fillMaxWidth(),
text = title,
color = enabled.toEnabledColor(),
)
},
supportingContent = supportingText?.let {
@@ -80,22 +78,23 @@ fun <T : DropdownOption> PreferenceDropdown(
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = it,
color = enabled.toSecondaryEnabledColor(),
)
}
},
trailingContent = ListItemContent.Custom(
content = {
content = { enabled ->
DropdownTrailingContent(
selectedOption = selectedOption,
options = options,
onSelectOption = onSelectOption,
expanded = isDropdownExpanded,
onExpandedChange = { isDropdownExpanded = it },
enabled = enabled,
modifier = Modifier.fillMaxSize(0.3f)
)
}
),
enabled = enabled,
onClick = { isDropdownExpanded = true }.takeIf { !isDropdownExpanded },
)
}
@@ -118,6 +117,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onSelectOption: (T) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -129,7 +129,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
text = selectedOption?.getText().orEmpty(),
maxLines = 1,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
color = enabled.toSecondaryEnabledColor(),
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
@@ -137,7 +137,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
Icon(
imageVector = CompoundIcons.ChevronDown(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
tint = enabled.toIconSecondaryEnabledColor(),
)
DropdownMenu(
expanded = expanded,
@@ -146,6 +146,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
) {
options.forEach { option ->
DropdownMenuItem(
enabled = enabled,
text = {
Text(
text = option.getText(),
@@ -206,5 +207,14 @@ internal fun PreferenceDropdownPreview() = ElementThemedPreview {
options = options,
onSelectOption = {},
)
PreferenceDropdown(
title = "Dropdown",
supportingText = "Options for dropdown",
icon = CompoundIcons.Threads(),
selectedOption = options.first(),
options = options,
onSelectOption = {},
enabled = false
)
}
}

View File

@@ -44,7 +44,6 @@ fun PreferenceSlide(
leadingContent = preferenceIcon(
icon = icon,
iconResourceId = iconResourceId,
enabled = enabled,
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
),
headlineContent = {

View File

@@ -42,7 +42,6 @@ fun PreferenceSwitch(
leadingContent = preferenceIcon(
icon = icon,
iconResourceId = iconResourceId,
enabled = enabled,
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
),
headlineContent = {

View File

@@ -34,11 +34,10 @@ fun preferenceIcon(
@DrawableRes iconResourceId: Int? = null,
showIconBadge: Boolean = false,
tintColor: Color? = null,
enabled: Boolean = true,
showIconAreaIfNoIcon: Boolean = false,
): ListItemContent.Custom? {
return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) {
ListItemContent.Custom {
ListItemContent.Custom { enabled ->
PreferenceIcon(
icon = icon,
iconResourceId = iconResourceId,

View File

@@ -21,6 +21,20 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
size = size,
)
/**
* Returns the power level of the user in the room.
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns the power level of [RoomMember.Role.Owner].
* Otherwise, checks the room's power levels for the user's power level.
* If no specific power level is set for the user, defaults to 0.
*/
fun RoomInfo.powerLevelOf(userId: UserId): Long {
return if (privilegedCreatorRole && creators.contains(userId)) {
RoomMember.Role.Owner(isCreator = true).powerLevel
} else {
roomPowerLevels?.powerLevelOf(userId = userId) ?: 0L
}
}
/**
* Returns the role of the user in the room.
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner].
@@ -28,9 +42,6 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
* If no specific power level is set for the user, defaults to [RoomMember.Role.User].
*/
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
return if (privilegedCreatorRole && creators.contains(userId)) {
RoomMember.Role.Owner(isCreator = true)
} else {
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User
}
val powerLevel = powerLevelOf(userId = userId)
return RoomMember.Role.forPowerLevel(powerLevel)
}

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.
*/
package io.element.android.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.roleOf
@Composable
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) {
value = userRole(sessionId)
.getOrDefault(RoomMember.Role.User)
.powerLevel
}
}
@Composable
fun BaseRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState()
val role = roomInfo.roleOf(sessionId)
return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner
}