Merge pull request #5685 from element-hq/feature/fga/role_and_permissions_rework

Change : roles and permissions
This commit is contained in:
ganfra 2025-11-06 19:13:01 +01:00 committed by GitHub
commit 0e09117231
198 changed files with 865 additions and 838 deletions

View file

@ -56,7 +56,7 @@ dependencies {
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.rolesandpermissions.api)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
api(projects.features.home.api) api(projects.features.home.api)

View file

@ -28,8 +28,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
@ -40,6 +38,8 @@ import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBl
import io.element.android.features.leaveroom.api.LeaveRoomRenderer import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
@ -93,10 +93,12 @@ class HomeFlowNode(
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy, changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy,
-> ->
commonLifecycle.coroutineScope.launch { commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged() val isNewOwnerSelected = changeRoomMemberRolesNode.waitForCompletion()
withContext(NonCancellable) { withContext(NonCancellable) {
backstack.pop() backstack.pop()
onNewOwnersSelected(changeRoomMemberRolesNode.roomId) if (isNewOwnerSelected) {
onNewOwnersSelected(changeRoomMemberRolesNode.roomId)
}
} }
} }
} }

View file

@ -11,7 +11,7 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.changeroommemberroles.api" namespace = "io.element.android.features.rolesandpermissions.api"
} }
dependencies { dependencies {

View file

@ -5,12 +5,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.api package io.element.android.features.rolesandpermissions.api
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -24,11 +23,11 @@ fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
interface NodeProxy { interface NodeProxy {
val roomId: RoomId val roomId: RoomId
suspend fun waitForRoleChanged() suspend fun waitForCompletion(): Boolean
} }
} }
enum class ChangeRoomMemberRolesListType : NodeInputs { enum class ChangeRoomMemberRolesListType {
SelectNewOwnersWhenLeaving, SelectNewOwnersWhenLeaving,
Admins, Admins,
Moderators Moderators

View file

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rolesandpermissions.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
fun interface RolesAndPermissionsEntryPoint : SimpleFeatureEntryPoint

View file

@ -14,7 +14,7 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.changeroommemberroles.impl" namespace = "io.element.android.features.rolesandpermissions.impl"
testOptions { testOptions {
unitTests { unitTests {
@ -26,7 +26,7 @@ android {
setupDependencyInjection() setupDependencyInjection()
dependencies { dependencies {
api(projects.features.changeroommemberroles.api) api(projects.features.rolesandpermissions.api)
implementation(projects.appnav) implementation(projects.appnav)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rolesandpermissions.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class)
class DefaultRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<RolesAndPermissionsFlowNode>(buildContext)
}
}

View file

@ -0,0 +1,136 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rolesandpermissions.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.coroutineScope
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.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.rolesandpermissions.impl.permissions.ChangeRoomPermissionsNode
import io.element.android.features.rolesandpermissions.impl.roles.ChangeRolesNode
import io.element.android.features.rolesandpermissions.impl.root.RolesAndPermissionsNode
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.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class RolesAndPermissionsFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object ChangeAdmins : NavTarget
@Parcelize
data object ChangeModerators : NavTarget
@Parcelize
data object ChangeRoomPermissions : NavTarget
}
private val asyncIndicatorState = AsyncIndicatorState()
override fun onBuilt() {
super.onBuilt()
whenChildAttached { lifecycle, node: ChangeRolesNode ->
lifecycle.coroutineScope.launch {
val changesSaved = node.waitForCompletion()
onChangeComplete(changesSaved)
}
}
}
private fun onChangeComplete(changesSaved: Boolean) {
backstack.pop()
if (changesSaved) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
val callback = object : RolesAndPermissionsNode.Callback {
override fun openAdminList() {
backstack.push(NavTarget.ChangeAdmins)
}
override fun openModeratorList() {
backstack.push(NavTarget.ChangeModerators)
}
override fun openEditPermissions() {
backstack.push(NavTarget.ChangeRoomPermissions)
}
}
createNode<RolesAndPermissionsNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
is NavTarget.ChangeAdmins -> {
val inputs = ChangeRolesNode.Inputs(ChangeRoomMemberRolesListType.Admins)
createNode<ChangeRolesNode>(buildContext = buildContext, plugins = listOf(inputs))
}
is NavTarget.ChangeModerators -> {
val inputs = ChangeRolesNode.Inputs(ChangeRoomMemberRolesListType.Moderators)
createNode<ChangeRolesNode>(buildContext = buildContext, plugins = listOf(inputs))
}
is NavTarget.ChangeRoomPermissions -> {
val callback = object : ChangeRoomPermissionsNode.Callback {
override fun onComplete(changesSaved: Boolean) {
onChangeComplete(changesSaved)
}
}
createNode<ChangeRoomPermissionsNode>(buildContext = buildContext, plugins = listOf(callback))
}
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
BackstackView()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
}
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.analytics package io.element.android.features.rolesandpermissions.impl.analytics
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -5,12 +5,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import io.element.android.libraries.matrix.api.room.RoomMember
interface ChangeRoomPermissionsEvent { interface ChangeRoomPermissionsEvent {
data class ChangeMinimumRoleForAction(val action: RoomPermissionType, val role: RoomMember.Role) : ChangeRoomPermissionsEvent data class ChangeMinimumRoleForAction(val action: RoomPermissionType, val role: SelectableRole) : ChangeRoomPermissionsEvent
data object Save : ChangeRoomPermissionsEvent data object Save : ChangeRoomPermissionsEvent
data object Exit : ChangeRoomPermissionsEvent data object Exit : ChangeRoomPermissionsEvent
data object ResetPendingActions : ChangeRoomPermissionsEvent data object ResetPendingActions : ChangeRoomPermissionsEvent

View file

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -16,25 +15,21 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
class ChangeRoomPermissionsNode( class ChangeRoomPermissionsNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ChangeRoomPermissionsPresenter.Factory, private val presenter: ChangeRoomPermissionsPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
@Parcelize interface Callback : Plugin {
data class Inputs( fun onComplete(changesSaved: Boolean)
val section: ChangeRoomPermissionsSection, }
) : NodeInputs, Parcelable
private val inputs: Inputs = inputs() private val callback: Callback = callback()
private val presenter = presenterFactory.create(inputs.section)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -42,14 +37,7 @@ class ChangeRoomPermissionsNode(
ChangeRoomPermissionsView( ChangeRoomPermissionsView(
modifier = modifier, modifier = modifier,
state = state, state = state,
onBackClick = this::navigateUp, onComplete = callback::onComplete,
) )
} }
} }
@Parcelize
enum class ChangeRoomPermissionsSection : Parcelable {
RoomDetails,
MessagesAndContent,
MembershipModeration,
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -15,50 +15,60 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedFactory import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.roomdetails.impl.analytics.trackPermissionChangeAnalytics
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AssistedInject @Inject
class ChangeRoomPermissionsPresenter( class ChangeRoomPermissionsPresenter(
@Assisted private val section: ChangeRoomPermissionsSection,
private val room: JoinedRoom, private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<ChangeRoomPermissionsState> { ) : Presenter<ChangeRoomPermissionsState> {
companion object { companion object {
internal fun itemsForSection(section: ChangeRoomPermissionsSection) = when (section) { private fun itemsForSection(section: RoomPermissionsSection) = when (section) {
ChangeRoomPermissionsSection.RoomDetails -> persistentListOf( RoomPermissionsSection.SpaceDetails,
RoomPermissionsSection.RoomDetails -> persistentListOf(
RoomPermissionType.ROOM_NAME, RoomPermissionType.ROOM_NAME,
RoomPermissionType.ROOM_AVATAR, RoomPermissionType.ROOM_AVATAR,
RoomPermissionType.ROOM_TOPIC, RoomPermissionType.ROOM_TOPIC,
) )
ChangeRoomPermissionsSection.MessagesAndContent -> persistentListOf( RoomPermissionsSection.MessagesAndContent -> persistentListOf(
RoomPermissionType.SEND_EVENTS, RoomPermissionType.SEND_EVENTS,
RoomPermissionType.REDACT_EVENTS, RoomPermissionType.REDACT_EVENTS,
) )
ChangeRoomPermissionsSection.MembershipModeration -> persistentListOf( RoomPermissionsSection.MembershipModeration -> persistentListOf(
RoomPermissionType.INVITE, RoomPermissionType.INVITE,
RoomPermissionType.KICK, RoomPermissionType.KICK,
RoomPermissionType.BAN, RoomPermissionType.BAN,
) )
} }
}
@AssistedFactory private fun RoomPermissionsSection.shouldShow(isSpace: Boolean): Boolean {
interface Factory { return when (this) {
fun create(section: ChangeRoomPermissionsSection): ChangeRoomPermissionsPresenter RoomPermissionsSection.RoomDetails -> !isSpace
RoomPermissionsSection.MembershipModeration -> true
RoomPermissionsSection.MessagesAndContent -> !isSpace
RoomPermissionsSection.SpaceDetails -> isSpace
}
}
internal fun buildItems(isSpace: Boolean) =
RoomPermissionsSection.entries
.filter { section -> section.shouldShow(isSpace) }
.associateWith { itemsForSection(it) }
.toImmutableMap()
} }
private val items: ImmutableList<RoomPermissionType> = itemsForSection(section) private val itemsBySection = buildItems(isSpace = room.info().isSpace)
private var initialPermissions by mutableStateOf<RoomPowerLevelsValues?>(null) private var initialPermissions by mutableStateOf<RoomPowerLevelsValues?>(null)
private var currentPermissions by mutableStateOf<RoomPowerLevelsValues?>(null) private var currentPermissions by mutableStateOf<RoomPowerLevelsValues?>(null)
@ -80,15 +90,20 @@ class ChangeRoomPermissionsPresenter(
fun handleEvent(event: ChangeRoomPermissionsEvent) { fun handleEvent(event: ChangeRoomPermissionsEvent) {
when (event) { when (event) {
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> { is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
val powerLevel = when (event.role) {
SelectableRole.Admin -> RoomMember.Role.Admin.powerLevel
SelectableRole.Moderator -> RoomMember.Role.Moderator.powerLevel
SelectableRole.Everyone -> RoomMember.Role.User.powerLevel
}
currentPermissions = when (event.action) { currentPermissions = when (event.action) {
RoomPermissionType.BAN -> currentPermissions?.copy(ban = event.role.powerLevel) RoomPermissionType.BAN -> currentPermissions?.copy(ban = powerLevel)
RoomPermissionType.INVITE -> currentPermissions?.copy(invite = event.role.powerLevel) RoomPermissionType.INVITE -> currentPermissions?.copy(invite = powerLevel)
RoomPermissionType.KICK -> currentPermissions?.copy(kick = event.role.powerLevel) RoomPermissionType.KICK -> currentPermissions?.copy(kick = powerLevel)
RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = event.role.powerLevel) RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = powerLevel)
RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = event.role.powerLevel) RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = powerLevel)
RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = event.role.powerLevel) RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = powerLevel)
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = event.role.powerLevel) RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = event.role.powerLevel) RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
} }
} }
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save() is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
@ -106,9 +121,8 @@ class ChangeRoomPermissionsPresenter(
} }
} }
return ChangeRoomPermissionsState( return ChangeRoomPermissionsState(
section = section,
currentPermissions = currentPermissions, currentPermissions = currentPermissions,
items = items, itemsBySection = itemsBySection,
hasChanges = hasChanges, hasChanges = hasChanges,
saveAction = saveAction, saveAction = saveAction,
confirmExitAction = confirmExitAction, confirmExitAction = confirmExitAction,

View file

@ -0,0 +1,84 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
data class ChangeRoomPermissionsState(
val currentPermissions: RoomPowerLevelsValues?,
val itemsBySection: ImmutableMap<RoomPermissionsSection, ImmutableList<RoomPermissionType>>,
val hasChanges: Boolean,
val saveAction: AsyncAction<Unit>,
val confirmExitAction: AsyncAction<Unit>,
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
) {
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
if (currentPermissions == null) return null
val role = when (type) {
RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban)
RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite)
RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick)
RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.sendEvents)
RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents)
RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName)
RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar)
RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic)
}
return when (role) {
is RoomMember.Role.Owner,
RoomMember.Role.Admin -> SelectableRole.Admin
RoomMember.Role.Moderator -> SelectableRole.Moderator
RoomMember.Role.User -> SelectableRole.Everyone
}
}
}
enum class RoomPermissionsSection {
SpaceDetails,
RoomDetails,
MessagesAndContent,
MembershipModeration,
}
enum class SelectableRole : DropdownOption {
Admin {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.screen_room_member_list_role_administrator)
},
Moderator {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.screen_room_member_list_role_moderator)
},
Everyone {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.screen_room_change_permissions_everyone)
}
}
enum class RoomPermissionType {
BAN,
INVITE,
KICK,
SEND_EVENTS,
REDACT_EVENTS,
ROOM_NAME,
ROOM_AVATAR,
ROOM_TOPIC
}

View file

@ -5,47 +5,39 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableMap
class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPermissionsState> { class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPermissionsState> {
override val values: Sequence<ChangeRoomPermissionsState> override val values: Sequence<ChangeRoomPermissionsState>
get() = sequenceOf( get() = sequenceOf(
aChangeRoomPermissionsState(section = ChangeRoomPermissionsSection.RoomDetails), aChangeRoomPermissionsState(),
aChangeRoomPermissionsState(section = ChangeRoomPermissionsSection.MessagesAndContent), aChangeRoomPermissionsState(hasChanges = true),
aChangeRoomPermissionsState(section = ChangeRoomPermissionsSection.MembershipModeration), aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
aChangeRoomPermissionsState(section = ChangeRoomPermissionsSection.RoomDetails, hasChanges = true),
aChangeRoomPermissionsState(section = ChangeRoomPermissionsSection.RoomDetails, hasChanges = true, saveAction = AsyncAction.Loading),
aChangeRoomPermissionsState( aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes")) saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes"))
), ),
aChangeRoomPermissionsState( aChangeRoomPermissionsState(hasChanges = true, confirmExitAction = AsyncAction.ConfirmingNoParams),
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true,
confirmExitAction = AsyncAction.ConfirmingNoParams,
),
) )
} }
internal fun aChangeRoomPermissionsState( internal fun aChangeRoomPermissionsState(
section: ChangeRoomPermissionsSection,
currentPermissions: RoomPowerLevelsValues = previewPermissions(), currentPermissions: RoomPowerLevelsValues = previewPermissions(),
items: List<RoomPermissionType> = ChangeRoomPermissionsPresenter.itemsForSection(section), itemsBySection: Map<RoomPermissionsSection, ImmutableList<RoomPermissionType>> = ChangeRoomPermissionsPresenter.buildItems(false),
hasChanges: Boolean = false, hasChanges: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized, saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
confirmExitAction: AsyncAction<Unit> = AsyncAction.Uninitialized, confirmExitAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {}, eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
) = ChangeRoomPermissionsState( ) = ChangeRoomPermissionsState(
section = section,
currentPermissions = currentPermissions, currentPermissions = currentPermissions,
items = items.toImmutableList(), itemsBySection = itemsBySection.toImmutableMap(),
hasChanges = hasChanges, hasChanges = hasChanges,
saveAction = saveAction, saveAction = saveAction,
confirmExitAction = confirmExitAction, confirmExitAction = confirmExitAction,

View file

@ -0,0 +1,143 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
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.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRoomPermissionsView(
state: ChangeRoomPermissionsState,
onComplete: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
state.eventSink(ChangeRoomPermissionsEvent.Exit)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(R.string.screen_room_roles_and_permissions_permissions_header),
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) })
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
onClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
enabled = state.hasChanges,
)
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
state.itemsBySection.onEachIndexed { index, (section, items) ->
item {
ListSectionHeader(titleForSection(section), hasDivider = index > 0)
}
for (permissionType in items) {
item {
PreferenceDropdown(
title = titleForType(permissionType),
selectedOption = state.selectedRoleForType(permissionType),
options = SelectableRole.entries.toImmutableList(),
onSelectOption = { role ->
state.eventSink(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
action = permissionType,
role = role
)
)
}
)
}
}
}
}
}
AsyncActionView(
async = state.saveAction,
onSuccess = { onComplete(true) },
onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
)
AsyncActionView(
async = state.confirmExitAction,
onSuccess = { onComplete(false) },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_unsaved_changes_title),
content = stringResource(R.string.screen_room_change_role_unsaved_changes_description),
submitText = stringResource(CommonStrings.action_save),
cancelText = stringResource(CommonStrings.action_discard),
onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }
)
},
onErrorDismiss = {},
)
}
@Composable
private fun titleForSection(section: RoomPermissionsSection): String = when (section) {
RoomPermissionsSection.SpaceDetails -> stringResource(R.string.screen_room_roles_and_permissions_space_details)
RoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_roles_and_permissions_room_details)
RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_roles_and_permissions_messages_and_content)
RoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_roles_and_permissions_member_moderation)
}
@Composable
private fun titleForType(type: RoomPermissionType): String = when (type) {
RoomPermissionType.INVITE -> stringResource(R.string.screen_room_change_permissions_invite_people)
RoomPermissionType.KICK -> stringResource(R.string.screen_room_change_permissions_remove_people)
RoomPermissionType.BAN -> stringResource(R.string.screen_room_change_permissions_ban_people)
RoomPermissionType.SEND_EVENTS -> stringResource(R.string.screen_room_change_permissions_send_messages)
RoomPermissionType.REDACT_EVENTS -> stringResource(R.string.screen_room_change_permissions_delete_messages)
RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name)
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
}
@PreviewsDayNight
@Composable
internal fun ChangeRoomPermissionsViewPreview(@PreviewParameter(ChangeRoomPermissionsStateProvider::class) state: ChangeRoomPermissionsState) {
ElementPreview {
ChangeRoomPermissionsView(
state = state,
onComplete = {},
)
}
}

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -17,10 +17,11 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -40,17 +41,17 @@ class ChangeRolesNode(
private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole()) private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole())
private val stateFlow = launchMolecule { presenter.present() } private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() { suspend fun waitForCompletion(): Boolean {
stateFlow.first { it.savingState.isSuccess() } val successState = stateFlow.first { it.savingState.isSuccess() }
return successState.savingState.dataOrNull().orFalse()
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
ChangeRolesView( ChangeRolesView(
modifier = modifier,
state = state, state = state,
navigateUp = this::navigateUp, modifier = modifier,
) )
} }
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -15,17 +15,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.rolesandpermissions.impl.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -51,6 +52,7 @@ class ChangeRolesPresenter(
private val room: JoinedRoom, private val room: JoinedRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@RoomCoroutineScope private val roomCoroutineScope: CoroutineScope,
) : Presenter<ChangeRolesState> { ) : Presenter<ChangeRolesState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@ -59,7 +61,6 @@ class ChangeRolesPresenter(
@Composable @Composable
override fun present(): ChangeRolesState { override fun present(): ChangeRolesState {
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) } val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
var query by rememberSaveable { mutableStateOf<String?>(null) } var query by rememberSaveable { mutableStateOf<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
@ -103,7 +104,6 @@ class ChangeRolesPresenter(
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean { fun canChangeMemberRole(userId: UserId): Boolean {
// This is used to group the
val currentUserRole = roomInfo.roleOf(room.sessionId) val currentUserRole = roomInfo.roleOf(room.sessionId)
val otherUserRole = roomInfo.roleOf(userId) val otherUserRole = roomInfo.roleOf(userId)
return currentUserRole.powerLevel > otherUserRole.powerLevel return currentUserRole.powerLevel > otherUserRole.powerLevel
@ -142,7 +142,7 @@ class ChangeRolesPresenter(
saveState.value = AsyncAction.ConfirmingNoParams saveState.value = AsyncAction.ConfirmingNoParams
} }
!saveState.value.isLoading() -> { !saveState.value.isLoading() -> {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState) roomCoroutineScope.save(usersWithRole.value, selectedUsers, saveState)
} }
} }
} }
@ -213,9 +213,9 @@ class ChangeRolesPresenter(
saveState.value = AsyncAction.Failure(it) saveState.value = AsyncAction.Failure(it)
} }
.onSuccess { .onSuccess {
saveState.value = AsyncAction.Success(true)
// Asynchronously reload the room members // Asynchronously reload the room members
launch { room.updateMembers() } launch { room.updateMembers() }
saveState.value = AsyncAction.Success(true)
} }
} }
} }

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@ -29,8 +29,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -39,9 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
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.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -77,10 +75,8 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun ChangeRolesView( fun ChangeRolesView(
state: ChangeRolesState, state: ChangeRolesState,
navigateUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val latestNavigateUp by rememberUpdatedState(newValue = navigateUp)
BackHandler(enabled = !state.isSearchActive) { BackHandler(enabled = !state.isSearchActive) {
state.eventSink(ChangeRolesEvent.Exit) state.eventSink(ChangeRolesEvent.Exit)
} }
@ -168,18 +164,9 @@ fun ChangeRolesView(
val asyncIndicatorState = rememberAsyncIndicatorState() val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState) AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
AsyncActionView( AsyncActionView(
async = state.savingState, async = state.savingState,
onSuccess = { changeSaved -> onSuccess = {},
if (changeSaved) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
}
} else {
latestNavigateUp()
}
},
confirmationDialog = { confirming -> confirmationDialog = { confirming ->
when (confirming) { when (confirming) {
is AsyncAction.ConfirmingCancellation -> { is AsyncAction.ConfirmingCancellation -> {
@ -414,8 +401,7 @@ private fun MemberRow(
internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) { internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) {
ElementPreview { ElementPreview {
ChangeRolesView( ChangeRolesView(
state = state, state = state
navigateUp = {},
) )
} }
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -20,8 +20,8 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
@ -70,7 +70,7 @@ class ChangeRoomMemberRolesRootNode(
override val roomId: RoomId = inputs.joinedRoom.roomId override val roomId: RoomId = inputs.joinedRoom.roomId
override suspend fun waitForRoleChanged() { override suspend fun waitForCompletion(): Boolean {
waitForChildAttached<ChangeRolesNode>().waitForRoleChanged() return waitForChildAttached<ChangeRolesNode>().waitForCompletion()
} }
} }

View file

@ -5,13 +5,13 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@ -39,9 +39,8 @@ class RolesAndPermissionsNode(
interface Callback : Plugin, RolesAndPermissionsNavigator { interface Callback : Plugin, RolesAndPermissionsNavigator {
override fun openAdminList() override fun openAdminList()
override fun openModeratorList() override fun openModeratorList()
override fun openEditRoomDetailsPermissions() override fun openEditPermissions()
override fun openMessagesAndContentPermissions()
override fun openModerationPermissions()
override fun onBackClick() {} override fun onBackClick() {}
} }
@ -85,7 +84,5 @@ interface RolesAndPermissionsNavigator {
fun onBackClick() {} fun onBackClick() {}
fun openAdminList() {} fun openAdminList() {}
fun openModeratorList() {} fun openModeratorList() {}
fun openEditRoomDetailsPermissions() {} fun openEditPermissions() {}
fun openMessagesAndContentPermissions() {}
fun openModerationPermissions() {}
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -20,7 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -78,25 +78,16 @@ fun RolesAndPermissionsView(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit())) leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
) )
} }
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_permissions_header), hasDivider = true) HorizontalDivider()
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_room_details)) }, headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_permissions_header)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = { rolesAndPermissionsNavigator.openEditRoomDetailsPermissions() }, onClick = { rolesAndPermissionsNavigator.openEditPermissions() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_messages_and_content)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chat())),
onClick = { rolesAndPermissionsNavigator.openMessagesAndContentPermissions() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_member_moderation)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
onClick = { rolesAndPermissionsNavigator.openModerationPermissions() },
) )
HorizontalDivider() HorizontalDivider()
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_reset)) }, headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_reset)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
onClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, onClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) },
style = ListItemStyle.Destructive, style = ListItemStyle.Destructive,
) )

View file

@ -9,10 +9,10 @@
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</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_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string> <string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string>
<string name="screen_room_change_permissions_room_avatar">"Change room avatar"</string> <string name="screen_room_change_permissions_room_avatar">"Change avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</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_name">"Change name"</string>
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string> <string name="screen_room_change_permissions_room_topic">"Change topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</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_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_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
@ -66,5 +66,6 @@
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Reset permissions?"</string> <string name="screen_room_roles_and_permissions_reset_confirm_title">"Reset permissions?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</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_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_space_details">"Space details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string> <string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
</resources> </resources>

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@ -17,7 +17,6 @@ import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
import io.element.android.libraries.matrix.api.room.RoomMember.Role.User
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -26,19 +25,17 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class ChangeBaseRoomPermissionsPresenterTest { class ChangeRoomPermissionsPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val section = ChangeRoomPermissionsSection.RoomDetails val presenter = createChangeRoomPermissionsPresenter()
val presenter = createChangeRoomPermissionsPresenter(section = section)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Initial state, no permissions loaded // Initial state, no permissions loaded
awaitItem().run { awaitItem().run {
assertThat(this.section).isEqualTo(section)
assertThat(this.currentPermissions).isNull() assertThat(this.currentPermissions).isNull()
assertThat(this.items).isNotEmpty() assertThat(this.itemsBySection).isNotEmpty()
assertThat(this.hasChanges).isFalse() assertThat(this.hasChanges).isFalse()
assertThat(this.saveAction).isEqualTo(AsyncAction.Uninitialized) assertThat(this.saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(this.confirmExitAction).isEqualTo(AsyncAction.Uninitialized) assertThat(this.confirmExitAction).isEqualTo(AsyncAction.Uninitialized)
@ -50,42 +47,22 @@ class ChangeBaseRoomPermissionsPresenterTest {
} }
@Test @Test
fun `present - RoomDetails section contains the right items`() = runTest { fun `present - items by section are correct for room`() = runTest {
val section = ChangeRoomPermissionsSection.RoomDetails val presenter = createChangeRoomPermissionsPresenter()
val presenter = createChangeRoomPermissionsPresenter(section = section)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitUpdatedItem().items).containsExactly( val itemsBySection = awaitUpdatedItem().itemsBySection
assertThat(itemsBySection[RoomPermissionsSection.RoomDetails]).containsExactly(
RoomPermissionType.ROOM_NAME, RoomPermissionType.ROOM_NAME,
RoomPermissionType.ROOM_AVATAR, RoomPermissionType.ROOM_AVATAR,
RoomPermissionType.ROOM_TOPIC, RoomPermissionType.ROOM_TOPIC,
) )
} assertThat(itemsBySection[RoomPermissionsSection.MessagesAndContent]).containsExactly(
}
@Test
fun `present - MessagesAndContent section contains the right items`() = runTest {
val section = ChangeRoomPermissionsSection.MessagesAndContent
val presenter = createChangeRoomPermissionsPresenter(section = section)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitUpdatedItem().items).containsExactly(
RoomPermissionType.SEND_EVENTS, RoomPermissionType.SEND_EVENTS,
RoomPermissionType.REDACT_EVENTS, RoomPermissionType.REDACT_EVENTS,
) )
} assertThat(itemsBySection[RoomPermissionsSection.MembershipModeration]).containsExactly(
}
@Test
fun `present - MembershipModeration section contains the right items`() = runTest {
val section = ChangeRoomPermissionsSection.MembershipModeration
val presenter = createChangeRoomPermissionsPresenter(section = section)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitUpdatedItem().items).containsExactly(
RoomPermissionType.INVITE, RoomPermissionType.INVITE,
RoomPermissionType.KICK, RoomPermissionType.KICK,
RoomPermissionType.BAN, RoomPermissionType.BAN,
@ -103,7 +80,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse() assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
awaitItem().run { awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
@ -120,18 +97,18 @@ class ChangeBaseRoomPermissionsPresenterTest {
}.test { }.test {
val state = awaitUpdatedItem() val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator))
val items = cancelAndConsumeRemainingEvents() val itemsBySection = cancelAndConsumeRemainingEvents()
(items.last() as? Event.Item<ChangeRoomPermissionsState>)?.value?.run { (itemsBySection.last() as? Event.Item<ChangeRoomPermissionsState>)?.value?.run {
assertThat(currentPermissions).isEqualTo( assertThat(currentPermissions).isEqualTo(
RoomPowerLevelsValues( RoomPowerLevelsValues(
invite = Moderator.powerLevel, invite = Moderator.powerLevel,
@ -165,14 +142,14 @@ class ChangeBaseRoomPermissionsPresenterTest {
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse() assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, User)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Everyone))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Admin))
skipItems(7) skipItems(7)
assertThat(awaitItem().hasChanges).isTrue() assertThat(awaitItem().hasChanges).isTrue()
@ -230,7 +207,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse() assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
assertThat(awaitItem().hasChanges).isTrue() assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Save) state.eventSink(ChangeRoomPermissionsEvent.Save)
@ -259,7 +236,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val state = awaitUpdatedItem() val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
assertThat(awaitItem().hasChanges).isTrue() assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Exit) state.eventSink(ChangeRoomPermissionsEvent.Exit)
@ -285,13 +262,11 @@ class ChangeBaseRoomPermissionsPresenterTest {
} }
private fun createChangeRoomPermissionsPresenter( private fun createChangeRoomPermissionsPresenter(
section: ChangeRoomPermissionsSection = ChangeRoomPermissionsSection.RoomDetails,
room: FakeJoinedRoom = FakeJoinedRoom( room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }), baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }),
), ),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = ChangeRoomPermissionsPresenter( ) = ChangeRoomPermissionsPresenter(
section = section,
room = room, room = room,
analyticsService = analyticsService, analyticsService = analyticsService,
) )

View file

@ -5,40 +5,40 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.clickOnFirst import io.element.android.tests.testutils.clickOnFirst
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ChangeBaseRoomPermissionsViewTest { class ChangeRoomPermissionsViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `click on back icon invokes Exit`() { fun `click on back icon invokes Exit`() {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
eventsRecorder = recorder, state = aChangeRoomPermissionsState(
eventSink = recorder
)
) )
rule.pressBack() rule.pressBack()
recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) recorder.assertSingle(ChangeRoomPermissionsEvent.Exit)
@ -48,7 +48,9 @@ class ChangeBaseRoomPermissionsViewTest {
fun `click on back key invokes Exit`() { fun `click on back key invokes Exit`() {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
eventsRecorder = recorder, state = aChangeRoomPermissionsState(
eventSink = recorder
)
) )
rule.pressBackKey() rule.pressBackKey()
recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) recorder.assertSingle(ChangeRoomPermissionsEvent.Exit)
@ -59,11 +61,9 @@ class ChangeBaseRoomPermissionsViewTest {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
eventSink = recorder, eventSink = recorder,
), ),
eventsRecorder = recorder,
) )
rule.pressBackKey() rule.pressBackKey()
recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) recorder.assertSingle(ChangeRoomPermissionsEvent.Exit)
@ -74,12 +74,10 @@ class ChangeBaseRoomPermissionsViewTest {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
confirmExitAction = AsyncAction.ConfirmingNoParams, confirmExitAction = AsyncAction.ConfirmingNoParams,
eventSink = recorder, eventSink = recorder,
), ),
eventsRecorder = recorder,
) )
rule.clickOn(CommonStrings.action_discard) rule.clickOn(CommonStrings.action_discard)
recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) recorder.assertSingle(ChangeRoomPermissionsEvent.Exit)
@ -90,12 +88,10 @@ class ChangeBaseRoomPermissionsViewTest {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
confirmExitAction = AsyncAction.ConfirmingNoParams, confirmExitAction = AsyncAction.ConfirmingNoParams,
eventSink = recorder, eventSink = recorder,
), ),
eventsRecorder = recorder,
) )
rule.clickOnFirst(CommonStrings.action_save) rule.clickOnFirst(CommonStrings.action_save)
recorder.assertSingle(ChangeRoomPermissionsEvent.Save) recorder.assertSingle(ChangeRoomPermissionsEvent.Save)
@ -105,21 +101,19 @@ class ChangeBaseRoomPermissionsViewTest {
fun `click on a role item triggers ChangeRole event`() { fun `click on a role item triggers ChangeRole event`() {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
eventsRecorder = recorder, state = aChangeRoomPermissionsState(
) itemsBySection = persistentMapOf(
val admins = rule.activity.getText(R.string.screen_room_change_permissions_administrators).toString() // Makes sure there is only one item to click on
val moderators = rule.activity.getText(R.string.screen_room_change_permissions_moderators).toString() RoomPermissionsSection.RoomDetails to persistentListOf(RoomPermissionType.ROOM_NAME)
val users = rule.activity.getText(R.string.screen_room_change_permissions_everyone).toString() ),
rule.onAllNodesWithText(admins).onFirst().performClick() eventSink = recorder,
rule.onAllNodesWithText(moderators).onFirst().performClick()
rule.onAllNodesWithText(users).onFirst().performClick()
recorder.assertList(
listOf(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Admin),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Moderator),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.User),
) )
) )
rule.clickOn(R.string.screen_room_change_permissions_room_name)
rule.clickOn(R.string.screen_room_change_permissions_everyone)
recorder.assertSingle(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Everyone),
)
} }
@Test @Test
@ -127,11 +121,9 @@ class ChangeBaseRoomPermissionsViewTest {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
eventSink = recorder, eventSink = recorder,
), ),
eventsRecorder = recorder,
) )
rule.clickOn(CommonStrings.action_save) rule.clickOn(CommonStrings.action_save)
recorder.assertSingle(ChangeRoomPermissionsEvent.Save) recorder.assertSingle(ChangeRoomPermissionsEvent.Save)
@ -139,14 +131,13 @@ class ChangeBaseRoomPermissionsViewTest {
@Test @Test
fun `a successful save exits the screen`() { fun `a successful save exits the screen`() {
ensureCalledOnce { callback -> ensureCalledOnceWithParam(true) { callback ->
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
saveAction = AsyncAction.Success(Unit), saveAction = AsyncAction.Success(Unit),
), ),
onBackClick = callback onComplete = callback
) )
rule.clickOn(CommonStrings.action_save) rule.clickOn(CommonStrings.action_save)
} }
@ -157,12 +148,10 @@ class ChangeBaseRoomPermissionsViewTest {
val recorder = EventsRecorder<ChangeRoomPermissionsEvent>() val recorder = EventsRecorder<ChangeRoomPermissionsEvent>()
rule.setChangeRoomPermissionsRule( rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState( state = aChangeRoomPermissionsState(
section = ChangeRoomPermissionsSection.RoomDetails,
hasChanges = true, hasChanges = true,
saveAction = AsyncAction.Failure(IllegalStateException("Failed to set room power levels")), saveAction = AsyncAction.Failure(IllegalStateException("Failed to set room power levels")),
eventSink = recorder, eventSink = recorder,
), ),
eventsRecorder = recorder,
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
recorder.assertSingle(ChangeRoomPermissionsEvent.ResetPendingActions) recorder.assertSingle(ChangeRoomPermissionsEvent.ResetPendingActions)
@ -170,17 +159,13 @@ class ChangeBaseRoomPermissionsViewTest {
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRoomPermissionsRule( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRoomPermissionsRule(
eventsRecorder: EventsRecorder<ChangeRoomPermissionsEvent> = EventsRecorder(expectEvents = false), state: ChangeRoomPermissionsState = aChangeRoomPermissionsState(),
state: ChangeRoomPermissionsState = aChangeRoomPermissionsState( onComplete: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
section = ChangeRoomPermissionsSection.RoomDetails,
eventSink = eventsRecorder,
),
onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
ChangeRoomPermissionsView( ChangeRoomPermissionsView(
state = state, state = state,
onBackClick = onBackClick, onComplete = onComplete,
) )
} }
} }

View file

@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import org.junit.Test import org.junit.Test

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@ -351,21 +351,15 @@ class ChangeRolesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save) awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmingState.eventSink(ChangeRolesEvent.Save) confirmingState.eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java)
val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
} }
} }
@ -413,18 +407,12 @@ class ChangeRolesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save) awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java)
val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator))
} }
@ -491,7 +479,7 @@ class ChangeRolesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -501,8 +489,6 @@ class ChangeRolesPresenterTest {
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
} }
@ -520,7 +506,7 @@ class ChangeRolesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -529,7 +515,6 @@ class ChangeRolesPresenterTest {
awaitItem().eventSink(ChangeRolesEvent.Save) awaitItem().eventSink(ChangeRolesEvent.Save)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
val failedState = awaitItem() val failedState = awaitItem()
assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java) assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java)
@ -567,5 +552,6 @@ internal fun TestScope.createChangeRolesPresenter(
room = room, room = room,
dispatchers = dispatchers, dispatchers = dispatchers,
analyticsService = analyticsService, analyticsService = analyticsService,
roomCoroutineScope = this,
) )
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
@ -306,12 +305,10 @@ class ChangeRolesViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
state: ChangeRolesState, state: ChangeRolesState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
ChangeRolesView( ChangeRolesView(
state = state, state = state,
navigateUp = onBackClick,
) )
} }
} }

View file

@ -5,12 +5,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.changeroommemberroles.impl package io.element.android.features.rolesandpermissions.impl.roles
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow

View file

@ -5,13 +5,13 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.rolesandpermissions package io.element.android.features.rolesandpermissions.impl.root
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -83,14 +83,12 @@ class RolesAndPermissionsViewTest {
@Test @Test
@Config(qualifiers = "h640dp") @Config(qualifiers = "h640dp")
fun `tapping on any of the permission items open the change permissions screen`() { fun `tapping permission item open the change permissions screen`() {
ensureCalledTimes(3) { callback -> ensureCalledTimes(1) { callback ->
rule.setRolesAndPermissionsView( rule.setRolesAndPermissionsView(
openPermissionScreens = callback, openEditPermissions = callback,
) )
rule.clickOn(R.string.screen_room_roles_and_permissions_room_details) rule.clickOn(R.string.screen_room_roles_and_permissions_permissions_header)
rule.clickOn(R.string.screen_room_roles_and_permissions_messages_and_content)
rule.clickOn(R.string.screen_room_roles_and_permissions_member_moderation)
} }
} }
@ -184,7 +182,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoles
goBack: () -> Unit = EnsureNeverCalled(), goBack: () -> Unit = EnsureNeverCalled(),
openAdminList: () -> Unit = EnsureNeverCalled(), openAdminList: () -> Unit = EnsureNeverCalled(),
openModeratorList: () -> Unit = EnsureNeverCalled(), openModeratorList: () -> Unit = EnsureNeverCalled(),
openPermissionScreens: () -> Unit = EnsureNeverCalled(), openEditPermissions: () -> Unit = EnsureNeverCalled(),
) { ) {
setSafeContent { setSafeContent {
RolesAndPermissionsView( RolesAndPermissionsView(
@ -193,9 +191,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoles
override fun onBackClick() = goBack() override fun onBackClick() = goBack()
override fun openAdminList() = openAdminList() override fun openAdminList() = openAdminList()
override fun openModeratorList() = openModeratorList() override fun openModeratorList() = openModeratorList()
override fun openEditRoomDetailsPermissions() = openPermissionScreens() override fun openEditPermissions() = openEditPermissions()
override fun openModerationPermissions() = openPermissionScreens()
override fun openMessagesAndContentPermissions() = openPermissionScreens()
} }
) )
} }

View file

@ -10,11 +10,11 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.changeroommemberroles.test" namespace = "io.element.android.features.rolesandpermissions.test"
} }
dependencies { dependencies {
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.rolesandpermissions.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)

View file

@ -9,8 +9,8 @@ package io.element.android.features.changeroommemberroles.test
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.changeroommemberroles.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
lambdaError()
}
}

View file

@ -56,7 +56,7 @@ dependencies {
implementation(projects.features.verifysession.api) implementation(projects.features.verifysession.api)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.roommembermoderation.api) implementation(projects.features.roommembermoderation.api)
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.rolesandpermissions.api)
implementation(projects.features.invitepeople.api) implementation(projects.features.invitepeople.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
@ -69,7 +69,7 @@ dependencies {
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.features.changeroommemberroles.test) testImplementation(projects.features.rolesandpermissions.test)
testImplementation(projects.features.knockrequests.test) testImplementation(projects.features.knockrequests.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.test)

View file

@ -26,19 +26,19 @@ import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.poll.api.history.PollHistoryEntryPoint import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode 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.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
@ -82,6 +82,7 @@ class RoomDetailsFlowNode(
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint, private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val reportRoomEntryPoint: ReportRoomEntryPoint, private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>( ) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -156,10 +157,12 @@ class RoomDetailsFlowNode(
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy, changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy,
-> ->
commonLifecycle.coroutineScope.launch { commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged() val isNewOwnerSelected = changeRoomMemberRolesNode.waitForCompletion()
withContext(NonCancellable) { withContext(NonCancellable) {
backstack.pop() backstack.pop()
roomDetailsNode.onNewOwnersSelected() if (isNewOwnerSelected) {
roomDetailsNode.onNewOwnersSelected()
}
} }
} }
} }
@ -343,7 +346,7 @@ class RoomDetailsFlowNode(
} }
is NavTarget.AdminSettings -> { is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext) rolesAndPermissionsEntryPoint.createNode(this, buildContext)
} }
NavTarget.PinnedMessagesList -> { NavTarget.PinnedMessagesList -> {
val params = MessagesEntryPoint.Params( val params = MessagesEntryPoint.Params(

View file

@ -1,133 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.coroutineScope
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.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsSection
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 io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class RolesAndPermissionsFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val joinedRoom: JoinedRoom,
) : 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
@Parcelize
data class ChangeRoomPermissions(val section: ChangeRoomPermissionsSection) : NavTarget
}
override fun onBuilt() {
super.onBuilt()
whenChildAttached { lifecycle, node: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
lifecycle.coroutineScope.launch {
node.waitForRoleChanged()
backstack.pop()
}
}
}
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)
}
override fun openEditRoomDetailsPermissions() {
backstack.push(NavTarget.ChangeRoomPermissions(ChangeRoomPermissionsSection.RoomDetails))
}
override fun openMessagesAndContentPermissions() {
backstack.push(NavTarget.ChangeRoomPermissions(ChangeRoomPermissionsSection.MessagesAndContent))
}
override fun openModerationPermissions() {
backstack.push(NavTarget.ChangeRoomPermissions(ChangeRoomPermissionsSection.MembershipModeration))
}
}
createNode<RolesAndPermissionsNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
is NavTarget.AdminList -> {
changeRoomMemberRolesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
room = joinedRoom,
listType = ChangeRoomMemberRolesListType.Admins,
)
}
is NavTarget.ModeratorList -> {
changeRoomMemberRolesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
room = joinedRoom,
listType = ChangeRoomMemberRolesListType.Moderators,
)
}
is NavTarget.ChangeRoomPermissions -> {
val inputs = ChangeRoomPermissionsNode.Inputs(navTarget.section)
createNode<ChangeRoomPermissionsNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.ImmutableList
data class ChangeRoomPermissionsState(
val section: ChangeRoomPermissionsSection,
val currentPermissions: RoomPowerLevelsValues?,
val items: ImmutableList<RoomPermissionType>,
val hasChanges: Boolean,
val saveAction: AsyncAction<Unit>,
val confirmExitAction: AsyncAction<Unit>,
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
)
enum class RoomPermissionType {
BAN,
INVITE,
KICK,
SEND_EVENTS,
REDACT_EVENTS,
ROOM_NAME,
ROOM_AVATAR,
ROOM_TOPIC
}

View file

@ -1,189 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.async.AsyncActionView
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.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.Scaffold
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.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRoomPermissionsView(
state: ChangeRoomPermissionsState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
state.eventSink(ChangeRoomPermissionsEvent.Exit)
}
Scaffold(
modifier = modifier,
topBar = {
val title = when (state.section) {
ChangeRoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_change_permissions_room_details)
ChangeRoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_change_permissions_messages_and_content)
ChangeRoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_change_permissions_member_moderation)
}
TopAppBar(
titleStr = title,
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) })
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
onClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
enabled = state.hasChanges,
)
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
for ((index, permissionItem) in state.items.withIndex()) {
item {
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.Admin,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.Moderator,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.User,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
}
}
}
}
AsyncActionView(
async = state.saveAction,
onSuccess = { onBackClick() },
onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
)
AsyncActionView(
async = state.confirmExitAction,
onSuccess = { onBackClick() },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_unsaved_changes_title),
content = stringResource(R.string.screen_room_change_role_unsaved_changes_description),
submitText = stringResource(CommonStrings.action_save),
cancelText = stringResource(CommonStrings.action_discard),
onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }
)
},
onErrorDismiss = {},
)
}
@Composable
private fun SelectRoleItem(
permissionsItem: RoomPermissionType,
role: RoomMember.Role,
currentPermissions: RoomPowerLevelsValues?,
onClick: (RoomPermissionType, RoomMember.Role) -> Unit
) {
val title = when (role) {
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_permissions_administrators)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_permissions_moderators)
RoomMember.Role.User -> stringResource(R.string.screen_room_change_permissions_everyone)
else -> error("Unsupported role selected: $role")
}
ListItem(
headlineContent = { Text(text = title) },
trailingContent = if (currentPermissions?.isSelected(permissionsItem, role).orFalse()) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()))
} else {
null
},
style = ListItemStyle.Primary,
onClick = { onClick(permissionsItem, role) },
)
}
private fun RoomPowerLevelsValues.isSelected(item: RoomPermissionType, role: RoomMember.Role): Boolean {
return when (item) {
RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(ban) == role
RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(invite) == role
RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(kick) == role
RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(sendEvents) == role
RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(redactEvents) == role
RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(roomName) == role
RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(roomAvatar) == role
RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(roomTopic) == role
}
}
@Composable
private fun titleForSection(item: RoomPermissionType): String = when (item) {
RoomPermissionType.INVITE -> stringResource(R.string.screen_room_change_permissions_invite_people)
RoomPermissionType.KICK -> stringResource(R.string.screen_room_change_permissions_remove_people)
RoomPermissionType.BAN -> stringResource(R.string.screen_room_change_permissions_ban_people)
RoomPermissionType.SEND_EVENTS -> stringResource(R.string.screen_room_change_permissions_send_messages)
RoomPermissionType.REDACT_EVENTS -> stringResource(R.string.screen_room_change_permissions_delete_messages)
RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name)
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
}
@PreviewsDayNight
@Composable
internal fun ChangeRoomPermissionsViewPreview(@PreviewParameter(ChangeRoomPermissionsStateProvider::class) state: ChangeRoomPermissionsState) {
ElementPreview {
ChangeRoomPermissionsView(
state = state,
onBackClick = {},
)
}
}

View file

@ -14,10 +14,10 @@
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</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_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string> <string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string>
<string name="screen_room_change_permissions_room_avatar">"Change room avatar"</string> <string name="screen_room_change_permissions_room_avatar">"Change avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</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_name">"Change name"</string>
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string> <string name="screen_room_change_permissions_room_topic">"Change topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</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_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_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.test.FakeElementCallEntryPoint import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.test.FakeRolesAndPermissionsEntryPoint
import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint
import io.element.android.features.messages.test.FakeMessagesEntryPoint import io.element.android.features.messages.test.FakeMessagesEntryPoint
import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint
@ -58,6 +59,7 @@ class DefaultRoomDetailsEntryPointTest {
outgoingVerificationEntryPoint = FakeOutgoingVerificationEntryPoint(), outgoingVerificationEntryPoint = FakeOutgoingVerificationEntryPoint(),
reportRoomEntryPoint = FakeReportRoomEntryPoint(), reportRoomEntryPoint = FakeReportRoomEntryPoint(),
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(), changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(),
) )
} }
val callback = object : RoomDetailsEntryPoint.Callback { val callback = object : RoomDetailsEntryPoint.Callback {

View file

@ -10,13 +10,12 @@
package io.element.android.libraries.designsystem.components.preferences package io.element.android.libraries.designsystem.components.preferences
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -25,14 +24,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toEnabledColor
@ -87,6 +91,7 @@ fun <T : DropdownOption> PreferenceDropdown(
onSelectOption = onSelectOption, onSelectOption = onSelectOption,
expanded = isDropdownExpanded, expanded = isDropdownExpanded,
onExpandedChange = { isDropdownExpanded = it }, onExpandedChange = { isDropdownExpanded = it },
modifier = Modifier.fillMaxSize(0.3f)
) )
} }
), ),
@ -114,27 +119,29 @@ private fun <T : DropdownOption> DropdownTrailingContent(
onSelectOption: (T) -> Unit, onSelectOption: (T) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ExposedDropdownMenuBox( Row(
expanded = expanded,
onExpandedChange = onExpandedChange,
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) { ) {
Row( Text(
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable), text = selectedOption?.getText().orEmpty(),
verticalAlignment = Alignment.CenterVertically, maxLines = 1,
) { style = ElementTheme.typography.fontBodyMdRegular,
Text( color = ElementTheme.colors.textSecondary,
text = selectedOption?.getText().orEmpty(), overflow = TextOverflow.Ellipsis,
maxLines = 1, textAlign = TextAlign.End,
style = ElementTheme.typography.fontBodyMdRegular, modifier = Modifier.weight(1f),
color = ElementTheme.colors.textSecondary, )
) Icon(
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) imageVector = CompoundIcons.ChevronDown(),
} contentDescription = null,
ExposedDropdownMenu( tint = ElementTheme.colors.iconSecondary,
)
DropdownMenu(
expanded = expanded, expanded = expanded,
minWidth = 0.dp,
onDismissRequest = { onExpandedChange(false) }, onDismissRequest = { onExpandedChange(false) },
matchTextFieldWidth = false,
) { ) {
options.forEach { option -> options.forEach { option ->
DropdownMenuItem( DropdownMenuItem(
@ -144,6 +151,15 @@ private fun <T : DropdownOption> DropdownTrailingContent(
style = ElementTheme.typography.fontBodyMdRegular style = ElementTheme.typography.fontBodyMdRegular
) )
}, },
trailingIcon = {
if (option == selectedOption) {
Icon(
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
},
onClick = { onClick = {
onSelectOption(option) onSelectOption(option)
onExpandedChange(false) onExpandedChange(false)

View file

@ -10,8 +10,10 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
@ -24,22 +26,24 @@ fun DropdownMenu(
expanded: Boolean, expanded: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// By default add a 16.dp offset to the menu offset: DpOffset = DpOffset(x = 0.dp, y = 0.dp),
offset: DpOffset = DpOffset(x = 16.dp, y = 0.dp),
properties: PopupProperties = PopupProperties(focusable = true), properties: PopupProperties = PopupProperties(focusable = true),
minWidth: Dp = DropdownMenuDefaults.minWidth,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
// Note: the internal shape corner radius should be 8dp, but there is a 4p value hardcoded in the internal Surface component
androidx.compose.material3.DropdownMenu( androidx.compose.material3.DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = modifier modifier = modifier
.background(color = ElementTheme.colors.bgCanvasDefault) .background(color = ElementTheme.colors.bgCanvasDefaultLevel1)
.widthIn(min = minMenuWidth), .widthIn(min = minWidth),
shape = RoundedCornerShape(8.dp),
offset = offset, offset = offset,
properties = properties, properties = properties,
content = content content = content
) )
} }
private val minMenuWidth = 200.dp object DropdownMenuDefaults {
val minWidth = 200.dp
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.di.annotations
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for an active room.
*/
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class RoomCoroutineScope

View file

@ -0,0 +1,26 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.room.BaseRoom
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object RoomModule {
@RoomCoroutineScope
@Provides
fun providesSessionCoroutineScope(room: BaseRoom): CoroutineScope {
return room.roomCoroutineScope
}
}

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4885040583de68748dc10cf85e0f395f7ae8a0ef08d8aeed14826fafec22990
size 65024

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac14dd774fca2924f599bd831d4fcad9931011174108bae5da1bcf8a5db6c4cf
size 64688

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d20f0962fb92b64b0e8a507f2c30428d0349c176d1453ed423a6c447d350459e oid sha256:d986fd93989b178dc08957270a75fee126c9910a45d067ca7c3353d188b69850
size 47267 size 47377

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d39dbdbed4a1260170fc1a148860533320413efb367a5d31bc4111086378529c oid sha256:cb5c468b4f8235595bcfb3355fa29cfd43567d51e27785357bdecb13ab1dadd9
size 47157 size 47268

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ed87edce4fc8ea22a2ba6d666bbaf21b14a359e2a3eec828e2edede23d5d1b1e oid sha256:e16ace640aa43a2d6b923a46fec94ca41e1b9ce1632cbd0319c646c08dc1b167
size 47146 size 47257

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d446944a0408921659ef954036211fe341e8b0529b6e0c7ecc315998e14b8f43 oid sha256:f90e8efb79b3525fafdee503a0aa5280e1d5bf0275ea3be0e79ee323c7b03751
size 47136 size 47248

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:61706045ca364252d30d9a36d276b83aeee5e4be2552fbd7a97be9378ebc3a37 oid sha256:8afb2771627534be13f709b1e125d9d4ae26d4ca1a7db102b31ef14620bb45a9
size 46979 size 47089

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:23a53698059b50b46bcbb6cedd9434aaffa77db460d7495b9306c809d90d2126 oid sha256:4e7b71b415175d4cae28404ca4aa361929df63fb670b9b5c86c327dd6df73ddf
size 47270 size 47379

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:779879fc4b976584305eca916e42bd3cf1b6c08bf9e857bf842ec4cd537d7c3a oid sha256:693180156b1bad6c7e0f1ef698ec8b6787912c2ca2f7d94326e2cda043ba6819
size 46924 size 47034

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2ec601dec7eb57e457ddb351fbe8f966b8f51e3dde12f3e7130e8229f4ed3079 oid sha256:ba1df0d1d16e51d28ecbfcc34be2d7ffb6722efd8bc6089b4befb2d79f82ce5f
size 46559 size 46668

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c647eb273c63b6e28f91a87d9e85f00c83d7f0cb6748965cd0c1391cac7fe918 oid sha256:3268b8e9c04a5a966f7d5b991299e783d6efe04cb05800ef45b2fd226b1d10bf
size 52991 size 53105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ec2983e926cff79f13fa0851c4c7dd6c4fd0433f19c774136cef5cdd0ea8b52 oid sha256:e44bb25d897d6f4de241267c56c6e3bed1d68d3d21522c4d0200a82041ddc083
size 48932 size 48999

Some files were not shown because too many files have changed in this diff Show more