Adapt 'change roles' screens to the new creator/owner role (#5076)

* Replace `RoomMember.Role.CREATOR` with `RoomMember.Role.Owner` - Make `RoomMember.Role` a sealed interface instead

* Adapt room member role mapping to include the power level to distinguish between admins and owners

* Use new `RoomMember.Role` sealed interface through the app

* Change how `MembersByRole` groups members to add owners to the admins section

* Adapt the `ChangeRoles` screen to the new roles:
    - Owners can't modify other owner's roles.
    - They can modify the roles of any other user, without confirmation.

* Adapt 'roles and permissions' screen:
    - Owners can't demote themselves.
    - The admin count also counts owners.

* Add more tests and screenshots

* Add owners to its own section in the 'change roles' screen

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2025-07-29 16:07:16 +02:00
committed by GitHub
parent 9509ad5170
commit 8298404630
77 changed files with 663 additions and 301 deletions

View File

@@ -157,7 +157,7 @@ internal fun SuggestionsPickerViewPreview() {
powerLevel = 0L,
normalizedPowerLevel = 0L,
isIgnored = false,
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
membershipChangeReason = null,
)
val anAlias = remember { RoomAlias("#room:domain.org") }

View File

@@ -69,7 +69,7 @@ fun aDmRoomMember(
powerLevel: Long = 0,
normalizedPowerLevel: Long = powerLevel,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,

View File

@@ -13,10 +13,10 @@ import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsV
import io.element.android.services.analytics.api.AnalyticsService
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
RoomMember.Role.CREATOR -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
RoomMember.Role.ADMIN -> RoomModeration.Role.Administrator
RoomMember.Role.MODERATOR -> RoomModeration.Role.Moderator
RoomMember.Role.USER -> RoomModeration.Role.User
is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
RoomMember.Role.User -> RoomModeration.Role.User
}
internal fun analyticsMemberRoleForPowerLevel(powerLevel: Long): RoomModeration.Role {

View File

@@ -150,7 +150,7 @@ fun aRoomMember(
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,
@@ -178,8 +178,8 @@ fun aRoomMemberList() = persistentListOf(
aWalter(),
)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.ADMIN)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.MODERATOR)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)

View File

@@ -61,7 +61,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.isOwner
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
@@ -295,14 +294,11 @@ private fun RoomMemberListItem(
modifier: Modifier = Modifier,
) {
val member = roomMemberWithIdentity.roomMember
val roleText = if (member.isOwner()) {
stringResource(R.string.screen_room_member_list_role_owner)
} else {
when (member.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator)
else -> null
}
val roleText = when (member.role) {
RoomMember.Role.Admin -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_member_list_role_moderator)
is RoomMember.Role.Owner -> stringResource(R.string.screen_room_member_list_role_owner)
else -> null
}
MatrixUserRow(

View File

@@ -61,7 +61,7 @@ class RolesAndPermissionsNode @AssistedInject constructor(
room.roomInfoFlow
.filter { info ->
val role = info.roleOf(room.sessionId)
role != RoomMember.Role.ADMIN && role != RoomMember.Role.CREATOR
role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner
}
.take(1)
.onEach { navigateUp() }

View File

@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.activeRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -50,14 +51,23 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
val moderatorCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.MODERATOR)
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator)
}
}
val adminCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.ADMIN)
val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin)
val ownersCount = if (roomInfo.privilegedCreatorRole) {
val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false))
val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true))
superAdmins + creators
} else {
0
}
admins + ownersCount
}
}
val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val resetPermissionsAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@@ -83,8 +93,10 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
return RolesAndPermissionsState(
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
adminCount = adminCount,
moderatorCount = moderatorCount,
canDemoteSelf = canDemoteSelf.value,
changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value,
eventSink = { handleEvent(it) },

View File

@@ -10,8 +10,10 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.architecture.AsyncAction
data class RolesAndPermissionsState(
val roomSupportsOwnerRole: Boolean,
val adminCount: Int,
val moderatorCount: Int,
val canDemoteSelf: Boolean,
val changeOwnRoleAction: AsyncAction<Unit>,
val resetPermissionsAction: AsyncAction<Unit>,
val eventSink: (RolesAndPermissionsEvents) -> Unit,

View File

@@ -13,7 +13,7 @@ import io.element.android.libraries.architecture.AsyncAction
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
override val values: Sequence<RolesAndPermissionsState>
get() = sequenceOf(
aRolesAndPermissionsState(),
aRolesAndPermissionsState(roomSupportsOwners = false),
aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2),
aRolesAndPermissionsState(
adminCount = 1,
@@ -45,17 +45,22 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermis
moderatorCount = 2,
resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")),
),
aRolesAndPermissionsState(canDemoteSelf = false),
)
}
internal fun aRolesAndPermissionsState(
roomSupportsOwners: Boolean = true,
adminCount: Int = 0,
moderatorCount: Int = 0,
canDemoteSelf: Boolean = true,
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
resetPermissionsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
roomSupportsOwnerRole = roomSupportsOwners,
adminCount = adminCount,
canDemoteSelf = canDemoteSelf,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
resetPermissionsAction = resetPermissionsAction,

View File

@@ -55,8 +55,14 @@ fun RolesAndPermissionsView(
onBackClick = rolesAndPermissionsNavigator::onBackClick,
) {
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false)
val adminsTitle = if (state.roomSupportsOwnerRole) {
stringResource(R.string.screen_room_roles_and_permissions_admins_and_owners)
} else {
stringResource(R.string.screen_room_roles_and_permissions_admins)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_admins)) },
headlineContent = { Text(adminsTitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
trailingContent = ListItemContent.Text("${state.adminCount}"),
onClick = { rolesAndPermissionsNavigator.openAdminList() },
@@ -67,11 +73,13 @@ fun RolesAndPermissionsView(
trailingContent = ListItemContent.Text("${state.moderatorCount}"),
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
)
if (state.canDemoteSelf) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
)
}
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_permissions_header), hasDivider = true)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_room_details)) },
@@ -170,7 +178,7 @@ private fun ChangeOwnRoleBottomSheet(
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))
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
}
},
style = ListItemStyle.Destructive,
@@ -179,7 +187,7 @@ private fun ChangeOwnRoleBottomSheet(
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))
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
}
},
style = ListItemStyle.Destructive,

View File

@@ -44,8 +44,8 @@ class ChangeRolesNode @AssistedInject constructor(
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.ADMIN
is ListType.Moderators -> RoomMember.Role.MODERATOR
is ListType.Admins -> RoomMember.Role.Admin
is ListType.Moderators -> RoomMember.Role.Moderator
}
create(role)
}

View File

@@ -75,18 +75,17 @@ class ChangeRolesPresenter @AssistedInject constructor(
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState(initialValue = persistentListOf()) {
room.usersWithRole(role)
.map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous: PersistentList<MatrixUser> = value
value = users.toPersistentList()
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous: PersistentList<MatrixUser> = value
value = users.toPersistentList()
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
}
val roomMemberState by room.membersStateFlow.collectAsState()
@@ -97,7 +96,6 @@ class ChangeRolesPresenter @AssistedInject constructor(
.search(query.orEmpty())
.groupedByRole()
println(results)
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
@@ -109,9 +107,10 @@ class ChangeRolesPresenter @AssistedInject constructor(
val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean {
// An admin can't remove or demote another admin
val role = roomInfo.roleOf(userId)
return role !in listOf(RoomMember.Role.ADMIN, RoomMember.Role.CREATOR)
// This is used to group the
val currentUserRole = roomInfo.roleOf(room.sessionId)
val otherUserRole = roomInfo.roleOf(userId)
return currentUserRole.powerLevel > otherUserRole.powerLevel
}
fun handleEvent(event: ChangeRolesEvent) {
@@ -133,11 +132,21 @@ class ChangeRolesPresenter @AssistedInject constructor(
selectedUsers.value = newList.toImmutableList()
}
is ChangeRolesEvent.Save -> {
if (role == RoomMember.Role.ADMIN && selectedUsers != usersWithRole && !saveState.value.isConfirming()) {
// Confirm adding admin
saveState.value = AsyncAction.ConfirmingNoParams
} else if (!saveState.value.isLoading()) {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin
val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole
val isConfirming = saveState.value.isConfirming()
val needsConfirmation = currentUserIsAdmin && isModifyingAdmins && hasChanges && !isConfirming
when {
needsConfirmation -> {
// Confirm modifying users
saveState.value = AsyncAction.ConfirmingNoParams
}
!saveState.value.isLoading() -> {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
}
}
}
is ChangeRolesEvent.ClearError -> {
@@ -175,10 +184,12 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
private fun List<RoomMember>.groupedByRole(): MembersByRole {
val groupedMembers = MembersByRole(this)
return MembersByRole(
admins = filter { it.role == RoomMember.Role.ADMIN }.sorted(),
moderators = filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
members = filter { it.role == RoomMember.Role.USER }.sorted(),
owners = groupedMembers.owners.sorted(),
admins = groupedMembers.admins.sorted(),
moderators = groupedMembers.moderators.sorted(),
members = groupedMembers.members.sorted(),
)
}
@@ -203,7 +214,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
for (selectedUser in toRemove) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
}
}

View File

@@ -30,17 +30,19 @@ data class ChangeRolesState(
)
data class MembersByRole(
val owners: ImmutableList<RoomMember>,
val admins: ImmutableList<RoomMember>,
val moderators: ImmutableList<RoomMember>,
val members: ImmutableList<RoomMember>,
) {
constructor(members: List<RoomMember>) : this(
admins = members.filter { it.role == RoomMember.Role.ADMIN }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
members = members.filter { it.role == RoomMember.Role.USER }.sorted(),
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
members = members.filter { it.role == RoomMember.Role.User }.sorted(),
)
fun isEmpty() = admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
}
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -15,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -24,7 +26,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
get() = sequenceOf(
aChangeRolesState(),
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.Moderator),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
@@ -41,11 +43,12 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(),
)
}
internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
role: RoomMember.Role = RoomMember.Role.Admin,
query: String? = null,
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
@@ -84,3 +87,47 @@ internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
)
internal fun aChangeRolesStateWithOwners() = aChangeRolesState(
role = RoomMember.Role.Admin,
searchResults = SearchBarResultState.Results(
MembersByRole(
members = persistentListOf(
aRoomMember(
userId = UserId("@alice:server.org"),
displayName = "Alice",
role = RoomMember.Role.Owner(isCreator = true),
),
aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Bob",
role = RoomMember.Role.Owner(isCreator = false),
),
aRoomMember(
userId = UserId("@carol:server.org"),
displayName = "Carol",
role = RoomMember.Role.Admin,
),
aRoomMember(
userId = UserId("@david:server.org"),
displayName = "David",
role = RoomMember.Role.User,
),
)
),
),
canRemoveMember = { userId ->
when (userId) {
UserId("@alice:server.org") -> false // Owner - creator
UserId("@bob:server.org") -> false // Owner - super admin
UserId("@carol:server.org") -> true // Admin
UserId("@david:server.org") -> true // User
else -> false
}
},
selectedUsers = persistentListOf(
aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
aMatrixUser(id = "@bob:server.org", displayName = "Bob"),
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
)
)

View File

@@ -57,6 +57,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -96,9 +97,9 @@ fun ChangeRolesView(
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
titleStr = when (state.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_role_moderators_title)
RoomMember.Role.CREATOR, RoomMember.Role.USER -> error("This should never be reached")
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title)
is RoomMember.Role.Owner, RoomMember.Role.User -> error("This should never be reached")
},
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
@@ -187,7 +188,7 @@ fun ChangeRolesView(
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.ADMIN) {
if (state.role == RoomMember.Role.Admin) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
@@ -234,10 +235,30 @@ private fun SearchResultsList(
item {
selectedUsersList(selectedUsers)
}
if (searchResults.owners.isNotEmpty()) {
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_owners)) }
item {
Text(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
text = stringResource(R.string.screen_room_change_role_moderators_owner_section_footer),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
items(searchResults.owners, key = { it.userId }) { roomMember ->
ListMemberItem(
roomMember = roomMember,
canRemoveMember = canRemoveMember,
onToggleSelection = onToggleSelection,
selectedUsers = selectedUsers
)
}
}
if (searchResults.admins.isNotEmpty()) {
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
// Add a footer for the admin section in change role to moderator screen
if (currentRole == RoomMember.Role.MODERATOR) {
if (currentRole == RoomMember.Role.Moderator) {
item {
Text(
modifier = Modifier
@@ -303,20 +324,24 @@ private fun ListMemberItem(
) {
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit) = {
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onToggleSelection(roomMember) },
enabled = canToggle,
)
if (canToggle) {
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onToggleSelection(roomMember) },
)
}
}
Column {
MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE,
trailingContent = trailingContent,
)
HorizontalDivider()
}
MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE,
trailingContent = trailingContent,
)
}
@Composable

View File

@@ -55,15 +55,15 @@ internal fun aChangeRoomPermissionsState(
private fun previewPermissions(): RoomPowerLevelsValues {
return RoomPowerLevelsValues(
// MembershipModeration section
invite = RoomMember.Role.ADMIN.powerLevel,
kick = RoomMember.Role.MODERATOR.powerLevel,
ban = RoomMember.Role.USER.powerLevel,
invite = RoomMember.Role.Admin.powerLevel,
kick = RoomMember.Role.Moderator.powerLevel,
ban = RoomMember.Role.User.powerLevel,
// MessagesAndContent section
redactEvents = RoomMember.Role.MODERATOR.powerLevel,
sendEvents = RoomMember.Role.ADMIN.powerLevel,
redactEvents = RoomMember.Role.Moderator.powerLevel,
sendEvents = RoomMember.Role.Admin.powerLevel,
// RoomDetails section
roomName = RoomMember.Role.ADMIN.powerLevel,
roomAvatar = RoomMember.Role.MODERATOR.powerLevel,
roomTopic = RoomMember.Role.USER.powerLevel,
roomName = RoomMember.Role.Admin.powerLevel,
roomAvatar = RoomMember.Role.Moderator.powerLevel,
roomTopic = RoomMember.Role.User.powerLevel,
)
}

View File

@@ -80,21 +80,21 @@ fun ChangeRoomPermissionsView(
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.MODERATOR,
role = RoomMember.Role.Moderator,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
@@ -135,9 +135,10 @@ private fun SelectRoleItem(
onClick: (RoomPermissionType, RoomMember.Role) -> Unit
) {
val title = when (role) {
RoomMember.Role.ADMIN, RoomMember.Role.CREATOR -> stringResource(R.string.screen_room_change_permissions_administrators)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_permissions_moderators)
RoomMember.Role.USER -> stringResource(R.string.screen_room_change_permissions_everyone)
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_permissions_administrators)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_permissions_moderators)
RoomMember.Role.User -> stringResource(R.string.screen_room_change_permissions_everyone)
else -> error("Unsupported role selected: $role")
}
ListItem(
headlineContent = { Text(text = title) },

View File

@@ -28,6 +28,7 @@
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Owners automatically have admin privileges."</string>
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
@@ -99,12 +100,14 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Admins and owners"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
<string name="screen_room_roles_and_permissions_owners">"Owners"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Once you reset permissions, you will lose the current settings."</string>

View File

@@ -66,7 +66,7 @@ class RolesAndPermissionPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
runCurrent()
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)
@@ -87,7 +87,7 @@ class RolesAndPermissionPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
runCurrent()
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)

View File

@@ -47,12 +47,30 @@ class RolesAndPermissionsViewTest {
fun `tapping on Admins opens admin list`() {
ensureCalledOnce { callback ->
rule.setRolesAndPermissionsView(
aRolesAndPermissionsState(
roomSupportsOwners = false,
eventSink = EventsRecorder(expectEvents = false)
),
openAdminList = callback,
)
rule.clickOn(R.string.screen_room_roles_and_permissions_admins)
}
}
@Test
fun `tapping on Admins and Owners opens admin list`() {
ensureCalledOnce { callback ->
rule.setRolesAndPermissionsView(
aRolesAndPermissionsState(
roomSupportsOwners = true,
eventSink = EventsRecorder(expectEvents = false)
),
openAdminList = callback,
)
rule.clickOn(R.string.screen_room_roles_and_permissions_admins_and_owners)
}
}
@Test
fun `tapping on Moderators opens moderators list`() {
ensureCalledOnce { callback ->
@@ -126,7 +144,7 @@ class RolesAndPermissionsViewTest {
)
rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)
rule.mainClock.advanceTimeBy(1_000L)
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
}
@Test
@@ -140,7 +158,7 @@ class RolesAndPermissionsViewTest {
)
rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
rule.mainClock.advanceTimeBy(1_000L)
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
}
@Test
@@ -160,6 +178,7 @@ class RolesAndPermissionsViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRolesAndPermissionsView(
state: RolesAndPermissionsState = aRolesAndPermissionsState(
roomSupportsOwners = false,
eventSink = EventsRecorder(expectEvents = false),
),
goBack: () -> Unit = EnsureNeverCalled(),

View File

@@ -12,6 +12,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -23,6 +24,7 @@ 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.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
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
@@ -43,7 +45,7 @@ class ChangeRolesPresenterTest {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(role).isEqualTo(RoomMember.Role.ADMIN)
assertThat(role).isEqualTo(RoomMember.Role.Admin)
assertThat(query).isNull()
assertThat(isSearchActive).isFalse()
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
@@ -70,6 +72,76 @@ class ChangeRolesPresenterTest {
}
}
@Test
fun `present - canChangeRole of users with lower power level unless they are owners`() = runTest {
val creatorUserId = UserId("@creator:matrix.org")
val superAdminUserId = UserId("@super_admin:matrix.org")
val room = FakeJoinedRoom().apply {
// User is a creator, so they can change roles of other members. So is `creatorUserId`.
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId, creatorUserId),
roomPowerLevels = RoomPowerLevels(
defaultRoomPowerLevelValues(),
users = persistentMapOf(
// bob is Admin
A_USER_ID_2 to RoomMember.Role.Admin.powerLevel,
// carol is Moderator
A_USER_ID_3 to RoomMember.Role.Moderator.powerLevel,
// super_admin is Owner - Superadmin
superAdminUserId to RoomMember.Role.Owner(isCreator = false).powerLevel,
)
)
)
)
val roomMemberList = aRoomMemberList() + listOf(
// Owner - superadmin
aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = true)),
// Owner - creator
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
)
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toPersistentList()))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().run {
assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin
assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator
assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner
}
}
}
@Test
fun `present - when modifying admins, creators are displayed too`() = runTest {
val room = FakeJoinedRoom().apply {
val creatorUserId = UserId("@creator:matrix.org")
val memberList = aRoomMemberList()
.plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId))
.toPersistentList()
givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId)))
givenRoomMembersState(RoomMembersState.Ready(memberList))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().searchResults.run {
assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java)
val results = (this as SearchBarResultState.Results).results
assertThat(results.admins).isNotEmpty()
assertThat(results.owners).isNotEmpty()
assertThat(results.owners.last().role).isEqualTo(RoomMember.Role.Owner(isCreator = true))
}
}
}
@Test
fun `present - ToggleSearchActive changes the value`() = runTest {
val room = FakeJoinedRoom().apply {
@@ -145,7 +217,7 @@ class ChangeRolesPresenterTest {
fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -167,7 +239,7 @@ class ChangeRolesPresenterTest {
fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -196,7 +268,7 @@ class ChangeRolesPresenterTest {
fun `present - Exit will display success if no pending changes`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -216,7 +288,7 @@ class ChangeRolesPresenterTest {
fun `present - CancelExit will remove exit confirmation`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -242,7 +314,7 @@ class ChangeRolesPresenterTest {
fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -273,9 +345,9 @@ class ChangeRolesPresenterTest {
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -302,9 +374,9 @@ class ChangeRolesPresenterTest {
fun `present - CancelSave will remove the confirmation dialog`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -331,10 +403,10 @@ class ChangeRolesPresenterTest {
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.MODERATOR)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator)))
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.MODERATOR,
role = RoomMember.Role.Moderator,
room = room,
analyticsService = analyticsService
)
@@ -358,15 +430,55 @@ class ChangeRolesPresenterTest {
}
}
@Test
fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId),
roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2)
)
)
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Admin,
room = room,
analyticsService = analyticsService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
}
}
@Test
fun `present - Save can handle failures and ClearError clears them`() = runTest {
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.MODERATOR)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -399,7 +511,7 @@ class ChangeRolesPresenterTest {
}
private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.ADMIN,
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),

View File

@@ -41,11 +41,25 @@ class ChangeRolesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `passing a 'USER' role throws an exception`() {
fun `passing a 'User' role throws an exception`() {
val exception = runCatchingExceptions {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
eventSink = EnsureNeverCalledWithParam(),
),
)
}.exceptionOrNull()
assertThat(exception).isNotNull()
}
@Test
fun `passing an 'Owner' role throws an exception`() {
val exception = runCatchingExceptions {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = true),
eventSink = EnsureNeverCalledWithParam(),
),
)
@@ -166,7 +180,7 @@ class ChangeRolesViewTest {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
@@ -183,7 +197,7 @@ class ChangeRolesViewTest {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,

View File

@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.room.aRoomMember
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
@@ -22,22 +24,28 @@ class MembersByRoleTest {
@Test
fun `constructor - with single member list categorizes and sorts members`() {
val members = listOf(
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
)
val membersByRole = MembersByRole(members = members)
assertThat(membersByRole.owners).containsExactly(
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
)
assertThat(membersByRole.admins).containsExactly(
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
)
assertThat(membersByRole.moderators).isEmpty()
assertThat(membersByRole.members).containsExactly(
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
)
}
@@ -46,24 +54,35 @@ class MembersByRoleTest {
val emptyMembersByRole = MembersByRole(emptyList())
assertThat(emptyMembersByRole.isEmpty()).isTrue()
val membersByRoleWithOwners = MembersByRole(
owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(),
)
assertThat(membersByRoleWithOwners.isEmpty()).isFalse()
val membersByRoleWithAdmins = MembersByRole(
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.ADMIN)),
owners = persistentListOf(),
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
moderators = persistentListOf(),
members = persistentListOf(),
)
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
val membersByRoleWithModerators = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.MODERATOR)),
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)),
members = persistentListOf(),
)
assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
val membersByRoleWithMembers = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.USER)),
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)),
)
assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
}

View File

@@ -15,9 +15,9 @@ 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.Role.ADMIN
import io.element.android.libraries.matrix.api.room.RoomMember.Role.MODERATOR
import io.element.android.libraries.matrix.api.room.RoomMember.Role.USER
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.RoomMember.Role.User
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@@ -100,13 +100,13 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(hasChanges).isTrue()
}
}
@@ -120,28 +120,28 @@ class ChangeBaseRoomPermissionsPresenterTest {
}.test {
val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator))
val items = cancelAndConsumeRemainingEvents()
(items.last() as? Event.Item<ChangeRoomPermissionsState>)?.value?.run {
assertThat(currentPermissions).isEqualTo(
RoomPowerLevelsValues(
invite = MODERATOR.powerLevel,
kick = MODERATOR.powerLevel,
ban = MODERATOR.powerLevel,
redactEvents = MODERATOR.powerLevel,
sendEvents = MODERATOR.powerLevel,
roomName = MODERATOR.powerLevel,
roomAvatar = MODERATOR.powerLevel,
roomTopic = MODERATOR.powerLevel,
invite = Moderator.powerLevel,
kick = Moderator.powerLevel,
ban = Moderator.powerLevel,
redactEvents = Moderator.powerLevel,
sendEvents = Moderator.powerLevel,
roomName = Moderator.powerLevel,
roomAvatar = Moderator.powerLevel,
roomTopic = Moderator.powerLevel,
)
)
}
@@ -162,17 +162,17 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, USER))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, User))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Admin))
skipItems(7)
assertThat(awaitItem().hasChanges).isTrue()
@@ -181,7 +181,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().hasChanges).isFalse()
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
assertThat(analyticsService.capturedEvents).containsExactlyElementsIn(
@@ -227,17 +227,17 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Save)
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
// Couldn't save the changes, so they're still pending
assertThat(hasChanges).isTrue()
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -245,7 +245,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions)
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(hasChanges).isTrue()
}
@@ -259,7 +259,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Exit)

View File

@@ -115,9 +115,9 @@ class ChangeBaseRoomPermissionsViewTest {
rule.onAllNodesWithText(users).onFirst().performClick()
recorder.assertList(
listOf(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.ADMIN),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.MODERATOR),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.USER),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Admin),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Moderator),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.User),
)
)
}

View File

@@ -61,8 +61,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = false,
canKick = false,
myUserRole = RoomMember.Role.USER,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel)
myUserRole = RoomMember.Role.User,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -81,7 +81,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.ADMIN,
myUserRole = RoomMember.Role.Admin,
targetRoomMember = null
)
createRoomMemberModerationPresenter(room = room).test {
@@ -103,8 +103,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.ADMIN,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel)
myUserRole = RoomMember.Role.Admin,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -125,8 +125,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.MODERATOR,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.ADMIN.powerLevel)
myUserRole = RoomMember.Role.Moderator,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.Admin.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@@ -147,7 +147,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.MODERATOR,
myUserRole = RoomMember.Role.Moderator,
targetRoomMember = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.BAN)
)
createRoomMemberModerationPresenter(room = room).test {
@@ -321,7 +321,7 @@ class RoomMemberModerationPresenterTest {
private fun aJoinedRoom(
canKick: Boolean = false,
canBan: Boolean = false,
myUserRole: RoomMember.Role = RoomMember.Role.USER,
myUserRole: RoomMember.Role = RoomMember.Role.User,
kickUserResult: Result<Unit> = Result.success(Unit),
banUserResult: Result<Unit> = Result.success(Unit),
unBanUserResult: Result<Unit> = Result.success(Unit),

View File

@@ -85,7 +85,7 @@ data class RoomInfo(
* Returns the list of users with the given [role] in this room.
*/
fun usersWithRole(role: RoomMember.Role): List<UserId> {
return if (role == RoomMember.Role.CREATOR) {
return if (role is RoomMember.Role.Owner && role.isCreator) {
this.creators
} else {
this.roomPowerLevels?.usersWithRole(role).orEmpty().toList()

View File

@@ -25,21 +25,34 @@ data class RoomMember(
/**
* Role of the RoomMember, based on its [powerLevel].
*/
enum class Role(val powerLevel: Long) {
CREATOR(Long.MAX_VALUE),
ADMIN(100L),
MODERATOR(50L),
USER(0L);
sealed interface Role {
data class Owner(val isCreator: Boolean) : Role
data object Admin : Role
data object Moderator : Role
data object User : Role
val powerLevel: Long
get() = when (this) {
is Owner -> if (isCreator) CREATOR_POWERLEVEL else SUPERADMIN_POWERLEVEL
Admin -> ADMIN_POWERLEVEL
Moderator -> MODERATOR_POWERLEVEL
User -> USER_POWERLEVEL
}
companion object {
const val SUPER_ADMIN_LEVEL = 150L
private const val CREATOR_POWERLEVEL = Long.MAX_VALUE
private const val SUPERADMIN_POWERLEVEL = 150L
private const val ADMIN_POWERLEVEL = 100L
private const val MODERATOR_POWERLEVEL = 50L
private const val USER_POWERLEVEL = 0L
fun forPowerLevel(powerLevel: Long): Role {
return when {
powerLevel > SUPER_ADMIN_LEVEL -> CREATOR
powerLevel >= ADMIN.powerLevel -> ADMIN
powerLevel >= MODERATOR.powerLevel -> MODERATOR
else -> USER
powerLevel == CREATOR_POWERLEVEL -> Owner(isCreator = true)
powerLevel >= SUPERADMIN_POWERLEVEL -> Owner(isCreator = false)
powerLevel >= ADMIN_POWERLEVEL -> Admin
powerLevel >= MODERATOR_POWERLEVEL -> Moderator
else -> User
}
}
}
@@ -87,11 +100,3 @@ fun RoomMember.toMatrixUser() = MatrixUser(
displayName = displayName,
avatarUrl = avatarUrl,
)
/**
* Returns `true` if the [RoomMember] is an owner of the room.
* Owners are defined as members with either the [RoomMember.Role.CREATOR] role or a power level greater than or equal to [RoomMember.Role.SUPER_ADMIN_LEVEL].
*/
fun RoomMember.isOwner(): Boolean {
return role == RoomMember.Role.CREATOR || powerLevel >= RoomMember.Role.SUPER_ADMIN_LEVEL
}

View File

@@ -25,14 +25,23 @@ data class RoomPowerLevels(
val values: RoomPowerLevelsValues,
private val users: ImmutableMap<UserId, Long>,
) {
/**
* Returns the power level of the user in the room.
*
* If the user is not found, returns 0.
*/
fun powerLevelOf(userId: UserId): Long {
return users[userId] ?: 0L
}
/**
* Returns the set of [UserId]s that have the given role in the room.
*
* **WARNING**: This method must not be used with the [RoomMember.Role.CREATOR] role. It'll result in a runtime error.
* **WARNING**: This method must not be used with a creator role. It'll result in a runtime error.
*/
fun usersWithRole(role: RoomMember.Role): Set<UserId> {
return if (role == RoomMember.Role.CREATOR) {
error("RoomPowerLevels.usersWithRole should not be used with CREATOR role, use roomInfo.creators instead")
return if (role is RoomMember.Role.Owner && role.isCreator) {
error("RoomPowerLevels.usersWithRole should not be used with a creator role, use roomInfo.creators instead")
} else {
users.filterValues { RoomMember.Role.forPowerLevel(it) == role }.keys
}
@@ -42,7 +51,7 @@ data class RoomPowerLevels(
* Returns the role of the user in the room based on their power level.
* If the user is not found, returns null.
*
* **WARNING**: This method must not be used with the [RoomMember.Role.CREATOR] role, as it won't return any results.
* **WARNING**: This method must not be used with a creator role, as it won't return any results.
*/
fun roleOf(userId: UserId): RoomMember.Role? {
return users[userId]?.let(RoomMember.Role::forPowerLevel)

View File

@@ -343,7 +343,7 @@ class RustMatrixClient(
powerLevelContentOverride = defaultRoomCreationPowerLevels.copy(
invite = if (createRoomParams.joinRuleOverride == JoinRule.Knock) {
// override the invite power level so it's the same as kick.
RoomMember.Role.MODERATOR.powerLevel.toInt()
RoomMember.Role.Moderator.powerLevel.toInt()
} else {
null
}

View File

@@ -128,7 +128,11 @@ class RustBaseRoom(
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> = withContext(roomDispatcher) {
runCatchingExceptions {
RoomMemberMapper.mapRole(innerRoom.suggestedRoleForUser(userId.value))
val powerLevel = roomInfoFlow.value.roomPowerLevels?.powerLevelOf(userId) ?: 0L
RoomMemberMapper.mapRole(
role = innerRoom.suggestedRoleForUser(userId.value),
powerLevel = powerLevel,
)
}
}

View File

@@ -16,25 +16,36 @@ import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
object RoomMemberMapper {
fun map(roomMember: RustRoomMember): RoomMember = RoomMember(
userId = UserId(roomMember.userId),
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
membership = mapMembership(roomMember.membership),
isNameAmbiguous = roomMember.isNameAmbiguous,
powerLevel = roomMember.powerLevel.into(),
normalizedPowerLevel = roomMember.normalizedPowerLevel.into(),
isIgnored = roomMember.isIgnored,
role = mapRole(roomMember.suggestedRoleForPowerLevel),
membershipChangeReason = roomMember.membershipChangeReason
)
fun map(roomMember: RustRoomMember): RoomMember {
val powerLevel = roomMember.powerLevel.into()
return RoomMember(
userId = UserId(roomMember.userId),
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
membership = mapMembership(roomMember.membership),
isNameAmbiguous = roomMember.isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = roomMember.normalizedPowerLevel.into(),
isIgnored = roomMember.isIgnored,
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason
)
}
fun mapRole(role: RoomMemberRole): RoomMember.Role =
fun mapRole(role: RoomMemberRole, powerLevel: Long?): RoomMember.Role =
when (role) {
RoomMemberRole.CREATOR -> RoomMember.Role.CREATOR
RoomMemberRole.ADMINISTRATOR -> RoomMember.Role.ADMIN
RoomMemberRole.MODERATOR -> RoomMember.Role.MODERATOR
RoomMemberRole.USER -> RoomMember.Role.USER
RoomMemberRole.CREATOR -> RoomMember.Role.Owner(isCreator = true)
RoomMemberRole.ADMINISTRATOR -> {
val superAdmin = RoomMember.Role.Owner(isCreator = false)
val powerLevelOrDefault = powerLevel ?: 0L
if (powerLevelOrDefault >= superAdmin.powerLevel) {
superAdmin
} else {
RoomMember.Role.Admin
}
}
RoomMemberRole.MODERATOR -> RoomMember.Role.Moderator
RoomMemberRole.USER -> RoomMember.Role.User
}
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =

View File

@@ -28,6 +28,6 @@ object RoomPowerLevelsValuesMapper {
}
fun PowerLevel.into(): Long = when (this) {
PowerLevel.Infinite -> RoomMember.Role.CREATOR.powerLevel
PowerLevel.Infinite -> RoomMember.Role.Owner(isCreator = true).powerLevel
is PowerLevel.Value -> this.value
}

View File

@@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomMembersIterator
import uniffi.matrix_sdk.RoomMemberRole
class FakeFfiRoom(
private val roomId: RoomId = A_ROOM_ID,
@@ -23,6 +24,7 @@ class FakeFfiRoom(
private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() },
private val leaveLambda: () -> Unit = { lambdaError() },
private val latestEventLambda: () -> EventTimelineItem? = { lambdaError() },
private val suggestedRoleForUserLambda: (String) -> RoomMemberRole = { lambdaError() },
private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value),
) : Room(NoPointer) {
override fun id(): String {
@@ -49,6 +51,10 @@ class FakeFfiRoom(
return latestEventLambda()
}
override suspend fun suggestedRoleForUser(userId: String): RoomMemberRole {
return suggestedRoleForUserLambda(userId)
}
override fun close() {
// No-op
}

View File

@@ -12,20 +12,26 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
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.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoom
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import uniffi.matrix_sdk.RoomMemberRole
class RustBaseRoomTest {
@Test
@@ -111,6 +117,29 @@ class RustBaseRoomTest {
}
}
@Test
fun `userRole loads and maps the role`() = runTest {
val rustBaseRoom = createRustBaseRoom(
initialRoomInfo = aRoomInfo(
roomPowerLevels = RoomPowerLevels(
values = RoomPowerLevelsValues(50, 50, 50, 50, 50, 50, 50, 50),
users = persistentMapOf(A_USER_ID to 100L)
)
),
innerRoom = FakeFfiRoom(
suggestedRoleForUserLambda = { userId ->
// Simulate the role suggestion based on power level
if (userId == A_USER_ID.value) RoomMemberRole.ADMINISTRATOR else RoomMemberRole.USER
}
),
)
val result = rustBaseRoom.userRole(A_USER_ID).getOrNull()
assertThat(result).isNotNull()
assertThat(result).isEqualTo(RoomMember.Role.Admin)
rustBaseRoom.destroy()
}
private suspend fun TestScope.leaveRoomAndObserveMembershipChange(
roomMembershipObserver: RoomMembershipObserver,
rustBaseRoom: RustBaseRoom,

View File

@@ -17,9 +17,19 @@ import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
class RoomMemberMapperTest {
@Test
fun mapRole() {
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.USER)).isEqualTo(RoomMember.Role.USER)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.MODERATOR)).isEqualTo(RoomMember.Role.MODERATOR)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR)).isEqualTo(RoomMember.Role.ADMIN)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.USER, 0L)).isEqualTo(RoomMember.Role.User)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.MODERATOR, 50L)).isEqualTo(RoomMember.Role.Moderator)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, 100L)).isEqualTo(RoomMember.Role.Admin)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, 150L)).isEqualTo(RoomMember.Role.Owner(isCreator = false))
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.CREATOR, Long.MAX_VALUE)).isEqualTo(RoomMember.Role.Owner(isCreator = true))
// `null` power level defaults to USER role
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, null)).isEqualTo(RoomMember.Role.Admin)
// Power level is only taken into account for ADMINISTRATOR role
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.USER, 123L)).isEqualTo(RoomMember.Role.User)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.MODERATOR, 1L)).isEqualTo(RoomMember.Role.Moderator)
assertThat(RoomMemberMapper.mapRole(RoomMemberRole.CREATOR, 0L)).isEqualTo(RoomMember.Role.Owner(isCreator = true))
}
@Test

View File

@@ -20,7 +20,7 @@ fun aRoomMember(
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,

View File

@@ -22,14 +22,14 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
/**
* Returns the role of the user in the room.
* If the user is a creator, returns [RoomMember.Role.CREATOR].
* If the user is a creator, returns [RoomMember.Role.Owner].
* Otherwise, checks the power levels and returns the corresponding role.
* If no specific power level is set for the user, defaults to [RoomMember.Role.USER].
* If no specific power level is set for the user, defaults to [RoomMember.Role.User].
*/
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
return if (creators.contains(userId)) {
RoomMember.Role.CREATOR
RoomMember.Role.Owner(isCreator = true)
} else {
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.USER
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User
}
}

View File

@@ -99,7 +99,7 @@ fun BaseRoom.canHandleKnockRequestsAsState(updateKey: Long): State<Boolean> {
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) {
value = userRole(sessionId)
.getOrDefault(RoomMember.Role.USER)
.getOrDefault(RoomMember.Role.User)
.powerLevel
}
}
@@ -108,7 +108,7 @@ fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
fun BaseRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState()
val role = roomInfo.roleOf(sessionId)
return role == RoomMember.Role.ADMIN || role == RoomMember.Role.CREATOR
return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner
}
@Composable