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 4534229e84
commit 51f67741ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 663 additions and 301 deletions

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