Merge branch 'develop' into kaylendog/history-sharing/alert

This commit is contained in:
Skye Elliot 2025-11-07 15:10:29 +00:00 committed by GitHub
commit 7abbb1bb19
271 changed files with 1991 additions and 1329 deletions

View file

@ -1,3 +1,86 @@
Changes in Element X v25.11.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.11.2 -->
## What's Changed
### ✨ Features
* Enable access to security and privacy by @bmarty in https://github.com/element-hq/element-x-android/pull/5566
* Add ability to forward a media from the media viewer and the gallery by @bmarty in https://github.com/element-hq/element-x-android/pull/5622
* Split notifications for messages in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5595
### 🙌 Improvements
* Enable `SyncNotificationsWithWorkManager` in nightly and debug builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5573
* Confirm exit without saving change in room details edit screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5618
* Space : add view members entry by @ganfra in https://github.com/element-hq/element-x-android/pull/5619
* Update notification sound by @bmarty in https://github.com/element-hq/element-x-android/pull/5667
* Use the new notification sound only on debug and nightly build by @bmarty in https://github.com/element-hq/element-x-android/pull/5673
* Make sure we know the session verification state before showing the options to verify the session by @bmarty in https://github.com/element-hq/element-x-android/pull/5677
### 🐛 Bugfixes
* Improve how brand color is applied. by @bmarty in https://github.com/element-hq/element-x-android/pull/5584
* Improve wellknown retrieval API by @bmarty in https://github.com/element-hq/element-x-android/pull/5587
* Clearing the room list search clears the search term too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5603
* Delete pin code only when the last session is deleted by @bmarty in https://github.com/element-hq/element-x-android/pull/5600
* Fix issues with WorkManager on Android 12 and below by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5606
* Fix marking a room as read re-instantiates its timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5628
* Display only valid emojis in recent emoji list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5612
* Fix navigation issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/5666
* Fix forward events from media viewer from pinned media timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/5669
* Try fixing 'Timeline Event object has already been destroyed' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5675
* Use the SDK Client to check whether a homeserver is compatible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5664
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5610
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5662
### 🧱 Build
* Remove `@Inject`, not necessary anymore when class is annotated with `@ContributesBinding` by @bmarty in https://github.com/element-hq/element-x-android/pull/5589
* Upgrade ktlint to 1.7.1 and ensure Renovate will upgrade the version by @bmarty in https://github.com/element-hq/element-x-android/pull/5638
* Improve architecture around Nodes by @bmarty in https://github.com/element-hq/element-x-android/pull/5641
* Move dependencies block out of the android block. by @bmarty in https://github.com/element-hq/element-x-android/pull/5674
* Always use the handleEvent(s) function the same way. by @bmarty in https://github.com/element-hq/element-x-android/pull/5672
### Dependency upgrades
* fix(deps): update metro to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5576
* fix(deps): update dependencyanalysis to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5577
* fix(deps): update dependency io.sentry:sentry-android to v8.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5586
* fix(deps): update dependency androidx.work:work-runtime-ktx to v2.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5590
* fix(deps): update dependency com.posthog:posthog-android to v3.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5594
* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5572
* Update plugin sonarqube to v7.0.1.6134 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5605
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5620
* fix(deps): update dependencyanalysis to v3.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5602
* fix(deps): update dependency com.github.matrix-org:matrix-analytics-events to v0.29.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5621
* fix(deps): update dependencyanalysis to v3.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5624
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.29 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5625
* fix(deps): update dependency io.sentry:sentry-android to v8.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5629
* fix(deps): update dependencyanalysis to v3.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5642
* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5644
* chore(deps): update danger/danger-js action to v13.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5652
* fix(deps): update dependency com.google.firebase:firebase-bom to v34.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5643
* fix(deps): update firebaseappdistribution to v5.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5640
* fix(deps): update metro to v0.7.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5663
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.31 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5657
* Update GitHub Artifact Actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5609
* Update dependency io.element.android:element-call-embedded to v0.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5598
* Update roborazzi to v1.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5676
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5681
* fix(deps): update metro to v0.7.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5683
### Others
* Improve code around Element .well-known configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/5565
* misc: display offline banner for all LoggedIn screens by @ganfra in https://github.com/element-hq/element-x-android/pull/5574
* Remove icon preview duplicate by @bmarty in https://github.com/element-hq/element-x-android/pull/5588
* Remove application navigation state usage in the push module by @bmarty in https://github.com/element-hq/element-x-android/pull/5596
* Design : update Home TopBar and RoomList Filters by @ganfra in https://github.com/element-hq/element-x-android/pull/5599
* Add missing tests on the analytic modules by @bmarty in https://github.com/element-hq/element-x-android/pull/5604
* design(space): let SpaceRoomItemView divider be full width by @ganfra in https://github.com/element-hq/element-x-android/pull/5597
* Update notification style by @bmarty in https://github.com/element-hq/element-x-android/pull/5607
* Improve how data is handled for the WorkManager. by @bmarty in https://github.com/element-hq/element-x-android/pull/5592
* Revert "Make sure declining a call stops observing the ringing call state" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5615
* Misc : space flow inject room by @ganfra in https://github.com/element-hq/element-x-android/pull/5614
* Enable `SyncNotificationsWithWorkManager` by default in release mode apps too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5646
* Revert "Update notification sound" by @bmarty in https://github.com/element-hq/element-x-android/pull/5671
* Introduce new query to count accounts by @bmarty in https://github.com/element-hq/element-x-android/pull/5678
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.0...v25.11.2
Changes in Element X v25.11.0 Changes in Element X v25.11.0
============================= =============================

View file

@ -32,7 +32,7 @@ android {
value = if (isEnterpriseBuild) { value = if (isEnterpriseBuild) {
BuildTimeConfig.BUG_REPORT_URL ?: "" BuildTimeConfig.BUG_REPORT_URL ?: ""
} else { } else {
"https://riot.im/bugreports/submit" "https://rageshakes.element.io/api/submit"
}, },
) )
buildConfigFieldStr( buildConfigFieldStr(

View file

@ -30,7 +30,6 @@ import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.space.api.SpaceEntryPoint
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.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -70,7 +69,6 @@ class RoomFlowNode(
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val membershipObserver: RoomMembershipObserver, private val membershipObserver: RoomMembershipObserver,
private val spaceEntryPoint: SpaceEntryPoint,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -105,9 +103,6 @@ class RoomFlowNode(
@Parcelize @Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget data class JoinedRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class JoinedSpace(val spaceId: RoomId) : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
@ -209,15 +204,6 @@ class RoomFlowNode(
) )
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
is NavTarget.JoinedSpace -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SpaceEntryPoint.Inputs(roomId = navTarget.spaceId),
callback = spaceCallback,
)
}
} }
} }

View file

@ -197,10 +197,6 @@ class JoinedRoomLoadedFlowNode(
callback.navigateToRoom(roomId, viaParameters) callback.navigateToRoom(roomId, viaParameters)
} }
override fun navigateToRoomDetails() {
backstack.push(NavTarget.RoomDetails)
}
override fun navigateToRoomMemberList() { override fun navigateToRoomMemberList() {
backstack.push(NavTarget.RoomMemberList) backstack.push(NavTarget.RoomMemberList)
} }

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -93,6 +93,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixuiTest)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)

View file

@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest

View file

@ -33,9 +33,9 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
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
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@ -415,6 +415,7 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(any()) } verify { notificationManagerCompat.cancel(any()) }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `IncomingCall - ignore expired ring lifetime`() = runTest { fun `IncomingCall - ignore expired ring lifetime`() = runTest {

View file

@ -24,11 +24,12 @@ class FakeEnterpriseService(
private val defaultHomeserverListResult: () -> List<String> = { emptyList() }, private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
initialBrandColor: Color? = null,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
private val brandColorState = MutableStateFlow<Color?>(null) private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors) private val semanticColorsState = MutableStateFlow(initialSemanticColors)
override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask { override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask {

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

@ -35,6 +35,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.poll.impl.R import io.element.android.features.poll.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -63,8 +64,7 @@ fun CreatePollView(
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) } val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
BackHandler(onBack = navBack) BackHandler(onBack = navBack)
if (state.showBackConfirmation) { if (state.showBackConfirmation) {
ConfirmationDialog( SaveChangesDialog(
content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android),
onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) }, onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
) )

View file

@ -77,7 +77,7 @@ class BugReportPresenter(
val sendingAction: MutableState<AsyncAction<Unit>> = remember { val sendingAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val formState: MutableState<BugReportFormState> = remember { val formState: MutableState<BugReportFormState> = rememberSaveable {
mutableStateOf(BugReportFormState.Default) mutableStateOf(BugReportFormState.Default)
} }
val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction) val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction)

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
@ -50,6 +48,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
@ -76,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)
} }
@ -167,24 +164,13 @@ 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 -> {
ConfirmationDialog( SaveChangesDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
) )
@ -415,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

@ -37,7 +37,7 @@ import io.element.android.libraries.designsystem.components.async.AsyncActionVie
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -152,9 +152,7 @@ fun RoomDetailsEditView(
}, },
confirmationDialog = { confirmationDialog = {
if (state.saveAction == AsyncAction.ConfirmingCancellation) { if (state.saveAction == AsyncAction.ConfirmingCancellation) {
ConfirmationDialog( SaveChangesDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) }, onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) },
onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
) )

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

@ -28,7 +28,6 @@ interface SpaceEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) fun navigateToRoom(roomId: RoomId, viaParameters: List<String>)
fun navigateToRoomDetails()
fun navigateToRoomMemberList() fun navigateToRoomMemberList()
} }
} }

View file

@ -18,6 +18,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
@ -26,14 +27,15 @@ import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.di.SpaceFlowGraph
import io.element.android.features.space.impl.leave.LeaveSpaceNode import io.element.android.features.space.impl.leave.LeaveSpaceNode
import io.element.android.features.space.impl.root.SpaceNode import io.element.android.features.space.impl.root.SpaceNode
import io.element.android.features.space.impl.settings.SpaceSettingsNode
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.callback import io.element.android.libraries.architecture.callback
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.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
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.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -42,6 +44,7 @@ import kotlinx.parcelize.Parcelize
class SpaceFlowNode( class SpaceFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
room: JoinedRoom,
spaceService: SpaceService, spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory, graphFactory: SpaceFlowGraph.Factory,
) : BaseFlowNode<SpaceFlowNode.NavTarget>( ) : BaseFlowNode<SpaceFlowNode.NavTarget>(
@ -52,15 +55,17 @@ class SpaceFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback: SpaceEntryPoint.Callback = callback() private val callback: SpaceEntryPoint.Callback = callback()
private val spaceRoomList = spaceService.spaceRoomList(inputs.roomId) private val spaceRoomList = spaceService.spaceRoomList(room.roomId)
override val graph = graphFactory.create(spaceRoomList) override val graph = graphFactory.create(spaceRoomList)
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Root : NavTarget data object Root : NavTarget
@Parcelize
data object Settings : NavTarget
@Parcelize @Parcelize
data object Leave : NavTarget data object Leave : NavTarget
} }
@ -77,7 +82,16 @@ class SpaceFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Leave -> { NavTarget.Leave -> {
createNode<LeaveSpaceNode>(buildContext, listOf(inputs)) val callback = object : LeaveSpaceNode.Callback {
override fun closeLeaveSpaceFlow() {
backstack.pop()
}
override fun navigateToRolesAndPermissions() {
// TODO
}
}
createNode<LeaveSpaceNode>(buildContext, listOf(callback))
} }
NavTarget.Root -> { NavTarget.Root -> {
val callback = object : SpaceNode.Callback { val callback = object : SpaceNode.Callback {
@ -85,8 +99,8 @@ class SpaceFlowNode(
callback.navigateToRoom(roomId, viaParameters) callback.navigateToRoom(roomId, viaParameters)
} }
override fun navigateToRoomDetails() { override fun navigateToSpaceSettings() {
callback.navigateToRoomDetails() backstack.push(NavTarget.Settings)
} }
override fun navigateToRoomMemberList() { override fun navigateToRoomMemberList() {
@ -97,7 +111,35 @@ class SpaceFlowNode(
backstack.push(NavTarget.Leave) backstack.push(NavTarget.Leave)
} }
} }
createNode<SpaceNode>(buildContext, listOf(inputs, callback)) createNode<SpaceNode>(buildContext, listOf(callback))
}
NavTarget.Settings -> {
val callback = object : SpaceSettingsNode.Callback {
override fun closeSettings() {
backstack.pop()
}
override fun navigateToSpaceInfo() {
// TODO
}
override fun navigateToSpaceMembers() {
callback.navigateToRoomMemberList()
}
override fun navigateToRolesAndPermissions() {
// TODO
}
override fun navigateToSecurityAndPrivacy() {
// TODO
}
override fun startLeaveSpaceFlow() {
backstack.push(NavTarget.Leave)
}
}
createNode<SpaceSettingsNode>(buildContext, listOf(callback))
} }
} }
} }

View file

@ -16,10 +16,10 @@ 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.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowScope import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.callback
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesNode(SpaceFlowScope::class) @ContributesNode(SpaceFlowScope::class)
@AssistedInject @AssistedInject
@ -27,12 +27,19 @@ class LeaveSpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
matrixClient: MatrixClient, matrixClient: MatrixClient,
room: JoinedRoom,
presenterFactory: LeaveSpacePresenter.Factory, presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs() interface Callback : Plugin {
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId) fun closeLeaveSpaceFlow()
fun navigateToRolesAndPermissions()
}
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId)
private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle) private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle)
private val callback: Callback = callback()
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -47,7 +54,8 @@ class LeaveSpaceNode(
val state = presenter.present() val state = presenter.present()
LeaveSpaceView( LeaveSpaceView(
state = state, state = state,
onCancel = ::navigateUp, onCancel = callback::closeLeaveSpaceFlow,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
modifier = modifier modifier = modifier
) )
} }

View file

@ -69,6 +69,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LeaveSpaceView( fun LeaveSpaceView(
state: LeaveSpaceState, state: LeaveSpaceState,
onCancel: () -> Unit, onCancel: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -130,6 +131,9 @@ fun LeaveSpaceView(
state.eventSink(LeaveSpaceEvents.LeaveSpace) state.eventSink(LeaveSpaceEvents.LeaveSpace)
}, },
onCancel = onCancel, onCancel = onCancel,
// TODO enable when navigation is ready
showRolesAndPermissionsButton = false, // state.isLastAdmin,
onRolesAndPermissionsClick = onRolesAndPermissionsClick,
) )
} }
} }
@ -210,6 +214,8 @@ private fun LeaveSpaceButtons(
showLeaveButton: Boolean, showLeaveButton: Boolean,
selectedRoomsCount: Int, selectedRoomsCount: Int,
onLeaveSpace: () -> Unit, onLeaveSpace: () -> Unit,
showRolesAndPermissionsButton: Boolean,
onRolesAndPermissionsClick: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
ButtonColumnMolecule( ButtonColumnMolecule(
@ -229,8 +235,14 @@ private fun LeaveSpaceButtons(
destructive = true, destructive = true,
) )
} }
// TODO For least admin space, add a button to open the settings. if (showRolesAndPermissionsButton) {
// See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600 Button(
text = stringResource(CommonStrings.action_go_to_roles_and_permissions),
onClick = onRolesAndPermissionsClick,
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Settings()),
)
}
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel), text = stringResource(CommonStrings.action_cancel),
@ -345,5 +357,6 @@ internal fun LeaveSpaceViewPreview(
LeaveSpaceView( LeaveSpaceView(
state = state, state = state,
onCancel = {}, onCancel = {},
onRolesAndPermissionsClick = {},
) )
} }

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