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 b64d7a267e
commit 6a75be7bf0
110 changed files with 2398 additions and 160 deletions

View file

@ -36,6 +36,7 @@ import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.members.details.avatar.AvatarPreviewNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -91,6 +92,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object PollHistory : NavTarget
@Parcelize
data object AdminSettings : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -120,6 +124,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openPollHistory() {
backstack.push(NavTarget.PollHistory)
}
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
@ -189,6 +197,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.PollHistory -> {
pollHistoryEntryPoint.createNode(this, buildContext)
}
is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext)
}
}
}

View file

@ -53,6 +53,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomNotificationSettings()
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openAdminSettings()
}
private val callbacks = plugins<Callback>()
@ -119,6 +120,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openAvatarPreview(name, url) }
}
private fun openAdminSettings() {
callbacks.forEach { it.openAdminSettings() }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@ -151,6 +156,7 @@ class RoomDetailsNode @AssistedInject constructor(
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
openAdminSettings = this::openAdminSettings,
)
}
}

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.coroutines.CoroutineScope
@ -71,6 +72,7 @@ class RoomDetailsPresenter @Inject constructor(
val leaveRoomState = leaveRoomPresenter.present()
val canShowNotificationSettings = remember { mutableStateOf(false) }
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val isUserAdmin = room.isOwnUserAdmin()
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
@ -150,6 +152,7 @@ class RoomDetailsPresenter @Inject constructor(
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
displayAdminSettings = !room.isDm && isUserAdmin,
eventSink = ::handleEvents,
)
}

View file

@ -37,6 +37,7 @@ data class RoomDetailsState(
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
val displayAdminSettings: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
)

View file

@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(),
aRoomDetailsState(displayAdminSettings = true),
aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState(isEncrypted = false),
@ -92,6 +92,7 @@ fun aRoomDetailsState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
isFavorite: Boolean = false,
displayAdminSettings: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@ -109,6 +110,7 @@ fun aRoomDetailsState(
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettings,
isFavorite = isFavorite,
displayAdminSettings = displayAdminSettings,
eventSink = eventSink
)

View file

@ -97,6 +97,7 @@ fun RoomDetailsView(
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
openAdminSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onShareMember() {
@ -160,30 +161,45 @@ fun RoomDetailsView(
)
}
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
NotificationSection(
isDefaultMode = state.roomNotificationSettings.isDefault,
openRoomNotificationSettings = openRoomNotificationSettings
PreferenceCategory {
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
NotificationItem(
isDefaultMode = state.roomNotificationSettings.isDefault,
openRoomNotificationSettings = openRoomNotificationSettings
)
}
FavoriteItem(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.displayAdminSettings) {
ListItem(
headlineContent = { Text("Roles and permissions") },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = openAdminSettings,
)
}
}
FavoriteSection(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.roomType is RoomDetailsType.Room) {
MembersSection(
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
if (state.canInvite) {
InviteSection(
invitePeople = invitePeople
)
val displayMemberListItem = state.roomType is RoomDetailsType.Room
val displayInviteMembersItem = state.canInvite
if (displayMemberListItem || displayInviteMembersItem) {
PreferenceCategory {
if (displayMemberListItem) {
MembersItem(
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
}
if (displayInviteMembersItem) {
InviteItem(
invitePeople = invitePeople
)
}
}
}
@ -349,7 +365,7 @@ private fun TopicSection(
}
@Composable
private fun NotificationSection(
private fun NotificationItem(
isDefaultMode: Boolean,
openRoomNotificationSettings: () -> Unit,
) {
@ -358,58 +374,49 @@ private fun NotificationSection(
} else {
stringResource(R.string.screen_room_details_notification_mode_custom)
}
PreferenceCategory {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
supportingContent = { Text(text = subtitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = openRoomNotificationSettings,
)
}
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
supportingContent = { Text(text = subtitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = openRoomNotificationSettings,
)
}
@Composable
private fun FavoriteSection(
private fun FavoriteItem(
isFavorite: Boolean,
onFavoriteChanges: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
PreferenceCategory(modifier = modifier) {
PreferenceSwitch(
icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
PreferenceSwitch(
icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
@Composable
private fun MembersSection(
private fun MembersItem(
memberCount: Long,
openRoomMemberList: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = openRoomMemberList,
)
}
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = openRoomMemberList,
)
}
@Composable
private fun InviteSection(
private fun InviteItem(
invitePeople: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = invitePeople,
)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = invitePeople,
)
}
@Composable
@ -481,5 +488,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
openAdminSettings = {},
)
}

View file

@ -54,7 +54,7 @@ class RoomInviteMembersPresenter @Inject constructor(
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var showSearchLoader = rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchMembers(roomMembers)
@ -99,7 +99,7 @@ class RoomInviteMembersPresenter @Inject constructor(
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {
value.filterNot { it == user }
value.filterNot { it.userId == user.userId }
} else {
value + user
}.toImmutableList()

View file

@ -48,7 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
@ -97,7 +97,7 @@ fun RoomInviteMembersView(
)
if (!state.isSearchActive) {
SelectedUsersList(
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
@ -157,7 +157,7 @@ private fun RoomInviteMembersSearchBar(
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (selectedUsers.isNotEmpty()) {
SelectedUsersList(
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = selectedUsers,
autoScroll = true,

View file

@ -0,0 +1,25 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RolesAndPermissionsEvents {
data object ChangeOwnRole : RolesAndPermissionsEvents
data class DemoteSelfTo(val role: RoomMember.Role) : RolesAndPermissionsEvents
data object CancelPendingAction : RolesAndPermissionsEvents
}

View file

@ -0,0 +1,98 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class RolesAndPermissionsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.AdminSettings,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object AdminSettings : NavTarget
@Parcelize
data object AdminList : NavTarget
@Parcelize
data object ModeratorList : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.AdminSettings -> {
val callback = object : RolesAndPermissionsNode.Callback {
override fun openAdminList() {
backstack.push(NavTarget.AdminList)
}
override fun openModeratorList() {
backstack.push(NavTarget.ModeratorList)
}
}
createNode<RolesAndPermissionsNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
is NavTarget.AdminList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
}
is NavTarget.ModeratorList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2023 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.features.roomdetails.impl.rolesandpermissions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
@ContributesNode(RoomScope::class)
class RolesAndPermissionsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RolesAndPermissionsPresenter,
private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins), RoomDetailsAdminSettingsNavigator {
interface Callback : Plugin {
fun openAdminList()
fun openModeratorList()
}
private val callback = plugins<Callback>().first()
override fun onBackPressed() = navigateUp()
override fun openAdminList() = callback.openAdminList()
override fun openModeratorList() = callback.openModeratorList()
override fun onBuilt() {
super.onBuilt()
// Reload members when the user sees this screen
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_RESUME) {
lifecycleScope.launch { room.updateMembers() }
}
}
})
// If the user is not an admin anymore, exit this section since they won't have permissions to use it
lifecycleScope.launch {
room.membersStateFlow
.map { state ->
state.roomMembers().orEmpty().find { it.userId == room.sessionId }
}
.filter { it?.role != RoomMember.Role.ADMIN }
.take(1)
.onEach { navigateUp() }
.collect()
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RolesAndPermissionsView(
state = state,
roomDetailsAdminSettingsNavigator = this,
modifier = modifier,
)
}
}
interface RoomDetailsAdminSettingsNavigator {
fun onBackPressed() {}
fun openAdminList() {}
fun openModeratorList() {}
}

View file

@ -0,0 +1,98 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class RolesAndPermissionsPresenter @Inject constructor(
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
) : Presenter<RolesAndPermissionsState> {
@Composable
override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val moderatorCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.MODERATOR)
}
}
val adminCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.ADMIN)
}
}
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: RolesAndPermissionsEvents) {
when (event) {
is RolesAndPermissionsEvents.ChangeOwnRole -> {
changeOwnRoleAction.value = AsyncAction.Confirming
}
is RolesAndPermissionsEvents.CancelPendingAction -> {
changeOwnRoleAction.value = AsyncAction.Uninitialized
}
is RolesAndPermissionsEvents.DemoteSelfTo -> coroutineScope.demoteSelfTo(
role = event.role,
changeOwnRoleAction = changeOwnRoleAction,
)
}
}
return RolesAndPermissionsState(
adminCount = adminCount,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction.value,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.demoteSelfTo(
role: RoomMember.Role,
changeOwnRoleAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
runUpdatingState(changeOwnRoleAction) {
room.updateUsersRoles(listOf(UserRoleChange(room.sessionId, role)))
}
}
private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int {
return if (this != null) {
userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role }
} else {
0
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.architecture.AsyncAction
data class RolesAndPermissionsState(
val adminCount: Int,
val moderatorCount: Int,
val changeOwnRoleAction: AsyncAction<Unit>,
val eventSink: (RolesAndPermissionsEvents) -> Unit,
)

View file

@ -0,0 +1,55 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
override val values: Sequence<RolesAndPermissionsState>
get() = sequenceOf(
aRolesAndPermissionsState(),
aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Confirming,
),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Loading,
),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Failure(IllegalStateException("Failed to change role")),
),
)
}
internal fun aRolesAndPermissionsState(
adminCount: Int = 0,
moderatorCount: Int = 0,
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
adminCount = adminCount,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
eventSink = eventSink,
)

View file

@ -0,0 +1,178 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RolesAndPermissionsView(
state: RolesAndPermissionsState,
roomDetailsAdminSettingsNavigator: RoomDetailsAdminSettingsNavigator,
modifier: Modifier = Modifier,
) {
PreferencePage(
modifier = modifier,
title = stringResource(R.string.screen_room_roles_and_permissions_title),
onBackPressed = roomDetailsAdminSettingsNavigator::onBackPressed,
) {
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_admins)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
trailingContent = ListItemContent.Text("${state.adminCount}"),
onClick = { roomDetailsAdminSettingsNavigator.openAdminList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
trailingContent = ListItemContent.Text("${state.moderatorCount}"),
onClick = { roomDetailsAdminSettingsNavigator.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()))
)
HorizontalDivider()
}
when (state.changeOwnRoleAction) {
is AsyncAction.Confirming -> {
ChangeOwnRoleBottomSheet(
eventSink = state.eventSink,
)
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }
)
}
else -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChangeOwnRoleBottomSheet(
eventSink: (RolesAndPermissionsEvents) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = if (LocalInspectionMode.current) {
sheetStateForPreview()
} else {
rememberModalBottomSheetState(skipPartiallyExpanded = true)
}
fun dismiss() {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
}
}
ModalBottomSheet(
modifier = Modifier
.systemBarsPadding()
.navigationBarsPadding(),
sheetState = sheetState,
onDismissRequest = ::dismiss,
) {
Text(
modifier = Modifier.padding(14.dp),
text = stringResource(R.string.screen_room_roles_and_permissions_change_my_role),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
Text(
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 16.dp),
text = stringResource(R.string.screen_room_change_role_confirm_demote_self_description),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
}
},
style = ListItemStyle.Destructive,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
}
},
style = ListItemStyle.Destructive,
)
ListItem(
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
}
},
style = ListItemStyle.Primary,
)
}
}
@PreviewsDayNight
@Composable
internal fun RoomDetailsAdminSettingsViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) {
ElementPreview {
RolesAndPermissionsView(
state = state,
roomDetailsAdminSettingsNavigator = object : RoomDetailsAdminSettingsNavigator {},
)
}
}

View file

@ -0,0 +1,30 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface ChangeRolesEvent {
data object ToggleSearchActive : ChangeRolesEvent
data class QueryChanged(val query: String?) : ChangeRolesEvent
data class UserSelectionToggled(val roomMember: RoomMember) : ChangeRolesEvent
data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent
data object ClearError : ChangeRolesEvent
data object CancelSave : ChangeRolesEvent
}

View file

@ -0,0 +1,71 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class ChangeRolesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ChangeRolesPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
sealed interface ListType : Parcelable {
@Parcelize
data object Admins : ListType
@Parcelize
data object Moderators : ListType
}
@Parcelize
data class Inputs(
val listType: ListType,
) : NodeInputs, Parcelable
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.ADMIN
is ListType.Moderators -> RoomMember.Role.MODERATOR
}
create(role)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ChangeRolesView(
modifier = modifier,
state = state,
onBackPressed = this::navigateUp,
)
}
}

View file

@ -0,0 +1,215 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class ChangeRolesPresenter @AssistedInject constructor(
@Assisted private val role: RoomMember.Role,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
) : Presenter<ChangeRolesState> {
@AssistedFactory
interface Factory {
fun create(role: RoomMember.Role): ChangeRolesPresenter
}
@Composable
override fun present(): ChangeRolesState {
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
var query by rememberSaveable { mutableStateOf<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
}
val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
}
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 } }
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
}
val roomMemberState by room.membersStateFlow.collectAsState()
// Update search results for every query change
LaunchedEffect(query, roomMemberState) {
val results = dataSource
.search(query.orEmpty())
.sorted()
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
SearchBarResultState.Results(results)
}
}
val hasPendingChanges = usersWithRole.value != selectedUsers.value
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
fun canChangeMemberRole(userId: UserId): Boolean {
// An admin can't remove or demote another admin
val powerLevel = roomInfo?.userPowerLevels?.get(userId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) != RoomMember.Role.ADMIN
}
fun handleEvent(event: ChangeRolesEvent) {
when (event) {
is ChangeRolesEvent.ToggleSearchActive -> {
searchActive = !searchActive
}
is ChangeRolesEvent.QueryChanged -> {
query = event.query
}
is ChangeRolesEvent.UserSelectionToggled -> {
val newList = selectedUsers.value.toMutableList()
val index = newList.indexOfFirst { it.userId == event.roomMember.userId }
if (index >= 0) {
newList.removeAt(index)
} else {
newList.add(event.roomMember.toMatrixUser())
}
selectedUsers.value = newList.toImmutableList()
}
is ChangeRolesEvent.Save -> {
if (role == RoomMember.Role.ADMIN && selectedUsers != usersWithRole && !saveState.value.isConfirming()) {
// Confirm adding admin
saveState.value = AsyncAction.Confirming
} else if (!saveState.value.isLoading()) {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
}
}
is ChangeRolesEvent.ClearError -> {
saveState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.Exit -> {
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) {
// Has pending changes, confirm exit
AsyncAction.Confirming
} else {
// No pending changes, exit immediately
AsyncAction.Success(Unit)
}
}
is ChangeRolesEvent.CancelExit -> {
exitState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.CancelSave -> {
saveState.value = AsyncAction.Uninitialized
}
}
}
return ChangeRolesState(
role = role,
query = query,
isSearchActive = searchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.value,
hasPendingChanges = hasPendingChanges,
exitState = exitState.value,
savingState = saveState.value,
canChangeMemberRole = ::canChangeMemberRole,
eventSink = ::handleEvent,
)
}
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}
private fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Unit>>,
) = launch {
saveState.value = AsyncAction.Loading
val toAdd = selectedUsers.value - usersWithRole
val toRemove = usersWithRole - selectedUsers.value
val changes: List<UserRoleChange> = buildList {
for (selectedUser in toAdd) {
add(UserRoleChange(selectedUser.userId, role))
}
for (selectedUser in toRemove) {
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
}
}
room.updateUsersRoles(changes)
.onFailure {
saveState.value = AsyncAction.Failure(it)
}
.onSuccess {
saveState.value = AsyncAction.Success(Unit)
}
}
}

View file

@ -0,0 +1,37 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ChangeRolesState(
val role: RoomMember.Role,
val query: String?,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>,
val savingState: AsyncAction<Unit>,
val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit,
)

View file

@ -0,0 +1,83 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
get() = sequenceOf(
aChangeRolesState(),
aChangeRolesState(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(
query = "Alice",
isSearchActive = true,
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Confirming),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
)
}
internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
query: String? = null,
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true },
) = ChangeRolesState(
role = role,
query = query,
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers,
hasPendingChanges = hasPendingChanges,
exitState = exitState,
savingState = savingState,
canChangeMemberRole = canRemoveMember,
eventSink = {},
)
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
)

View file

@ -0,0 +1,296 @@
/*
* 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.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Checkbox
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
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRolesView(
state: ChangeRolesState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
BackHandler {
if (state.isSearchActive) {
state.eventSink(ChangeRolesEvent.ToggleSearchActive)
} else {
state.eventSink(ChangeRolesEvent.Exit)
}
}
Box(modifier = modifier) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
topBar = {
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
title = {
val title = 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.USER -> error("This should never be reached")
}
Text(
text = title,
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.hasPendingChanges,
onClick = { state.eventSink(ChangeRolesEvent.Save) }
)
}
)
}
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues),
) {
val lazyListState = rememberLazyListState()
SearchBar(
modifier = Modifier.padding(bottom = 16.dp),
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
query = state.query.orEmpty(),
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
active = state.isSearchActive,
onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) },
resultState = state.searchResults,
) { members ->
SearchResultsList(
isSearchActive = true,
lazyListState = lazyListState,
searchResults = members,
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
selectedUsersList = {},
)
}
AnimatedVisibility(
visible = !state.isSearchActive,
enter = fadeIn(),
exit = fadeOut()
) {
Column {
SearchResultsList(
isSearchActive = false,
lazyListState = lazyListState,
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
selectedUsersList = { users ->
SelectedUsersRowList(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
selectedUsers = users,
onUserRemoved = {
state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId)))
},
canDeselect = { state.canChangeMemberRole(it.userId) },
)
}
)
}
}
}
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
when (state.exitState) {
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
)
}
is AsyncAction.Success -> {
SideEffect { updatedOnBackPressed() }
}
else -> Unit
}
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.ADMIN) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
is AsyncAction.Success -> {
LaunchedEffect(state.savingState) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
}
}
}
else -> Unit
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultsList(
isSearchActive: Boolean,
searchResults: ImmutableList<RoomMember>,
selectedUsers: ImmutableList<MatrixUser>,
canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit,
lazyListState: LazyListState,
selectedUsersList: @Composable (ImmutableList<MatrixUser>) -> Unit,
) {
LazyColumn(
state = lazyListState,
) {
item {
selectedUsersList(selectedUsers)
}
stickyHeader {
val textResId = if (isSearchActive) {
CommonStrings.common_search_results
} else {
R.string.screen_room_member_list_room_members_header_title
}
Text(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
text = stringResource(textResId),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
items(searchResults, key = { it.userId }) { roomMember ->
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
{
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onSelectionToggled(roomMember) },
)
}
} else {
null
}
MatrixUserRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
matrixUser = MatrixUser(
userId = roomMember.userId,
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
),
trailingContent = trailingContent,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) {
ElementPreview {
ChangeRolesView(
state = state,
onBackPressed = {},
)
}
}

View file

@ -9,6 +9,29 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string>
<string name="screen_polls_history_title">"Polls"</string>
<string name="screen_room_change_permissions_administrators">"Admins only"</string>
<string name="screen_room_change_permissions_ban_people">"Ban people"</string>
<string name="screen_room_change_permissions_delete_messages">"Delete messages"</string>
<string name="screen_room_change_permissions_everyone">"Everyone"</string>
<string name="screen_room_change_permissions_invite_people">"Invite people"</string>
<string name="screen_room_change_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people"</string>
<string name="screen_room_change_permissions_room_avatar">"Change Room Avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</string>
<string name="screen_room_change_permissions_room_name">"Change Room Name"</string>
<string name="screen_room_change_permissions_room_topic">"Change Room Topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Add Admin?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_details_add_topic_title">"Add topic"</string>
<string name="screen_room_details_already_a_member">"Already a member"</string>
<string name="screen_room_details_already_invited">"Already invited"</string>
@ -70,5 +93,17 @@
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<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_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_permissions_header">"Permissions"</string>
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
</resources>