Room admins can change user roles (#2423)

Allow Admins to modify room member roles:

- Add a 'roles and permissions' option for each room.
- Allow promoting users to admins, adding or removing moderators, and demote yourself if you're and admin.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-03-05 17:46:47 +01:00 committed by GitHub
parent 1d892b4bc8
commit b9d902e3fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 2398 additions and 160 deletions

View file

@ -29,12 +29,19 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import java.io.Closeable
import java.io.File
@ -56,7 +63,7 @@ interface MatrixRoom : Closeable {
/** Whether the room is a direct message. */
val isDm: Boolean get() = isDirect && isOneToOne
val roomInfoFlow: Flow<MatrixRoomInfo>
val roomInfoFlow: SharedFlow<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>>
/**
@ -91,6 +98,10 @@ interface MatrixRoom : Closeable {
suspend fun unsubscribeFromSync()
suspend fun userRole(userId: UserId): Result<RoomMember.Role>
suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit>
suspend fun userDisplayName(userId: UserId): Result<String?>
suspend fun userAvatarUrl(userId: UserId): Result<String?>
@ -144,6 +155,18 @@ interface MatrixRoom : Closeable {
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
canUserSendState(userId, StateEventType.CALL_MEMBER)
fun usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
return roomInfoFlow
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
.distinctUntilChanged()
.combine(membersStateFlow) { powerLevels, membersState ->
membersState.roomMembers()
.orEmpty()
.filter { powerLevels.containsKey(it.userId) }
.toPersistentList()
}
}
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>

View file

@ -17,8 +17,10 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@Immutable
data class MatrixRoomInfo(
@ -39,6 +41,7 @@ data class MatrixRoomInfo(
val activeMembersCount: Long,
val invitedMembersCount: Long,
val joinedMembersCount: Long,
val userPowerLevels: ImmutableMap<UserId, Long>,
val highlightCount: Long,
val notificationCount: Long,
val userDefinedNotificationMode: RoomNotificationMode?,

View file

@ -32,10 +32,20 @@ data class RoomMember(
/**
* Role of the RoomMember, based on its [powerLevel].
*/
enum class Role {
ADMIN,
MODERATOR,
USER
enum class Role(val powerLevel: Long) {
ADMIN(100L),
MODERATOR(50L),
USER(0L);
companion object {
fun forPowerLevel(powerLevel: Long): Role {
return when {
powerLevel >= ADMIN.powerLevel -> ADMIN
powerLevel >= MODERATOR.powerLevel -> MODERATOR
else -> USER
}
}
}
}
/**

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room.powerlevels
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
data class UserRoleChange(
val userId: UserId,
val role: RoomMember.Role,
) {
val powerLevel: Long = role.powerLevel
}

View file

@ -16,12 +16,15 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
@ -49,6 +52,7 @@ class MatrixRoomInfoMapper(
activeMembersCount = it.activeMembersCount.toLong(),
invitedMembersCount = it.invitedMembersCount.toLong(),
joinedMembersCount = it.joinedMembersCount.toLong(),
userPowerLevels = mapPowerLevels(it.userPowerLevels),
highlightCount = it.highlightCount.toLong(),
notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
@ -69,3 +73,7 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
}

View file

@ -36,8 +36,10 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -51,6 +53,7 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
@ -63,8 +66,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EventTimelineItem
@ -74,6 +80,7 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@ -102,7 +109,7 @@ class RustMatrixRoom(
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
override val roomInfoFlow: Flow<MatrixRoomInfo> = mxCallbackFlow {
override val roomInfoFlow: SharedFlow<MatrixRoomInfo> = mxCallbackFlow {
launch {
val initial = innerRoom.roomInfo().use(matrixRoomInfoMapper::map)
channel.trySend(initial)
@ -113,6 +120,7 @@ class RustMatrixRoom(
}
})
}
.shareIn(sessionCoroutineScope, SharingStarted.Eagerly, replay = 1)
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
launch {
@ -228,6 +236,19 @@ class RustMatrixRoom(
}
}
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> = withContext(coroutineDispatchers.io) {
runCatching {
RoomMemberMapper.mapRole(innerRoom.suggestedRoleForUser(userId.value))
}
}
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
return runCatching {
val powerLevelChanges = changes.map { UserPowerLevelUpdate(it.userId.value, it.powerLevel) }
innerRoom.updatePowerLevelsForUsers(powerLevelChanges)
}
}
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatching {
innerRoom.memberAvatarUrl(userId.value)

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -54,11 +55,14 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
@ -86,6 +90,7 @@ class FakeMatrixRoom(
private var unignoreResult: Result<Unit> = Result.success(Unit)
private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null)
private var userRoleResult = Result.success(RoomMember.Role.USER)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
@ -100,6 +105,7 @@ class FakeMatrixRoom(
private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
private var updateUserRoleResult = Result.success(Unit)
private var toggleReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
@ -170,7 +176,7 @@ class FakeMatrixRoom(
private var leaveRoomError: Throwable? = null
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
override val roomInfoFlow: SharedFlow<MatrixRoomInfo> = _roomInfoFlow
private val _roomTypingMembersFlow: MutableSharedFlow<List<UserId>> = MutableSharedFlow(replay = 1)
override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow
@ -206,6 +212,14 @@ class FakeMatrixRoom(
userAvatarUrlResult
}
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> {
return userRoleResult
}
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
return updateUserRoleResult
}
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
sendMessageMentions = mentions
Result.success(Unit)
@ -496,6 +510,14 @@ class FakeMatrixRoom(
userAvatarUrlResult = avatarUrl
}
fun givenUserRoleResult(role: Result<RoomMember.Role>) {
userRoleResult = role
}
fun givenUpdateUserRoleResult(result: Result<Unit>) {
updateUserRoleResult = result
}
fun givenJoinRoomResult(result: Result<Unit>) {
joinRoomResult = result
}
@ -668,6 +690,7 @@ fun aRoomInfo(
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<String> = emptyList()
) = MatrixRoomInfo(
id = id,
@ -691,5 +714,6 @@ fun aRoomInfo(
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
)

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
canRemove: Boolean,
onUserRemoved: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
@ -70,24 +71,26 @@ fun SelectedUser(
style = MaterialTheme.typography.bodyLarge,
)
}
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onUserRemoved(matrixUser) }
),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
if (canRemove) {
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onUserRemoved(matrixUser) }
),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
}
}
}
}
@ -97,6 +100,17 @@ fun SelectedUser(
internal fun SelectedUserPreview() = ElementPreview {
SelectedUser(
aMatrixUser(),
canRemove = true,
onUserRemoved = {},
)
}
@PreviewsDayNight
@Composable
internal fun SelectedUserCannotRemovePreview() = ElementPreview {
SelectedUser(
aMatrixUser(),
canRemove = false,
onUserRemoved = {},
)
}

View file

@ -46,11 +46,12 @@ import kotlinx.collections.immutable.toImmutableList
import kotlin.math.floor
@Composable
fun SelectedUsersList(
fun SelectedUsersRowList(
selectedUsers: ImmutableList<MatrixUser>,
onUserRemoved: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
autoScroll: Boolean = false,
canDeselect: (MatrixUser) -> Boolean = { true },
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val lazyListState = rememberLazyListState()
@ -105,11 +106,12 @@ fun SelectedUsersList(
.fillMaxWidth(),
contentPadding = contentPadding,
) {
itemsIndexed(selectedUsers.toList()) { index, matrixUser ->
itemsIndexed(selectedUsers.toList()) { index, selectedUser ->
Layout(
content = {
SelectedUser(
matrixUser = matrixUser,
matrixUser = selectedUser,
canRemove = canDeselect(selectedUser),
onUserRemoved = onUserRemoved,
)
},
@ -133,7 +135,7 @@ fun SelectedUsersList(
internal fun SelectedUsersListPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Two users that will be visible with no scrolling
SelectedUsersList(
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
onUserRemoved = {},
modifier = Modifier
@ -143,7 +145,7 @@ internal fun SelectedUsersListPreview() = ElementPreview {
// Multiple users that don't fit, so will be spaced out per the measure policy
for (i in 0..5) {
SelectedUsersList(
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
onUserRemoved = {},
modifier = Modifier

View file

@ -18,9 +18,12 @@ 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
@ -45,3 +48,10 @@ fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
value = canRedactOther().getOrElse { false }
}
}
@Composable
fun MatrixRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState(initial = null)
val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN
}

View file

@ -57,6 +57,7 @@
<string name="action_enter_pin">"Enter PIN"</string>
<string name="action_forgot_password">"Forgot password?"</string>
<string name="action_forward">"Forward"</string>
<string name="action_go_back">"Go back"</string>
<string name="action_invite">"Invite"</string>
<string name="action_invite_friends">"Invite people"</string>
<string name="action_invite_friends_to_app">"Invite people to %1$s"</string>
@ -181,6 +182,7 @@
<string name="common_room">"Room"</string>
<string name="common_room_name">"Room name"</string>
<string name="common_room_name_placeholder">"e.g. your project name"</string>
<string name="common_saved_changes">"Saved changes"</string>
<string name="common_saving">"Saving"</string>
<string name="common_screen_lock">"Screen lock"</string>
<string name="common_search_for_someone">"Search for someone"</string>
@ -224,6 +226,8 @@
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Success"</string>
<string name="dialog_title_warning">"Warning"</string>
<string name="dialog_unsaved_changes_description_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
<string name="dialog_unsaved_changes_title">"Save changes?"</string>
<string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string>
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
<string name="error_failed_loading_messages">"Failed loading messages"</string>