Merge pull request #5786 from element-hq/feature/bma/addAdminConfirmation

Ensure confirmation dialog is displayed when an admin add other admin to a room
This commit is contained in:
Benoit Marty 2025-11-21 17:06:42 +01:00 committed by GitHub
commit 80d799c4db
16 changed files with 260 additions and 124 deletions

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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
@ -104,7 +105,11 @@ class ChangeRolesPresenter(
} }
} }
val hasPendingChanges = usersWithRole.value != selectedUsers.value val hasPendingChanges by remember {
derivedStateOf {
usersWithRole.value.toSet() != selectedUsers.value.toSet()
}
}
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean { fun canChangeMemberRole(userId: UserId): Boolean {
@ -134,16 +139,20 @@ class ChangeRolesPresenter(
is ChangeRolesEvent.Save -> { is ChangeRolesEvent.Save -> {
val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin
val isModifyingAdmins = role == RoomMember.Role.Admin val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole
val isConfirming = saveState.value.isConfirming() val isConfirming = saveState.value.isConfirming()
val modifyingOwners = role is RoomMember.Role.Owner val modifyingOwners = role is RoomMember.Role.Owner
val confirmationValue = if (hasPendingChanges && !isConfirming) {
val needsConfirmation = (modifyingOwners || currentUserIsAdmin && isModifyingAdmins) && hasChanges && !isConfirming when {
modifyingOwners -> ConfirmingModifyingOwners
currentUserIsAdmin && isModifyingAdmins -> ConfirmingModifyingAdmins
else -> null
}
} else {
null
}
when { when {
needsConfirmation -> { confirmationValue != null -> {
// Confirm modifying users saveState.value = confirmationValue
saveState.value = AsyncAction.ConfirmingNoParams
} }
!saveState.value.isLoading() -> { !saveState.value.isLoading() -> {
roomCoroutineScope.save(usersWithRole.value, selectedUsers, saveState) roomCoroutineScope.save(usersWithRole.value, selectedUsers, saveState)

View file

@ -46,7 +46,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
selectedUsers = aMatrixUserList().take(1).toImmutableList(), selectedUsers = aMatrixUserList().take(1).toImmutableList(),
), ),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams), aChangeRolesStateWithSelectedUsers().copy(savingState = ConfirmingModifyingAdmins),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
@ -58,6 +58,8 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
) )
), ),
aChangeRolesStateWithOwners(role = RoomMember.Role.Owner(isCreator = false)), aChangeRolesStateWithOwners(role = RoomMember.Role.Owner(isCreator = false)),
aChangeRolesStateWithOwners(role = RoomMember.Role.Owner(isCreator = false))
.copy(savingState = ConfirmingModifyingOwners),
) )
} }

View file

@ -178,29 +178,23 @@ fun ChangeRolesView(
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
) )
} }
else -> { is ConfirmingModifyingOwners -> {
when (state.role) { ConfirmationDialog(
is RoomMember.Role.Owner -> { title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
ConfirmationDialog( content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title), submitText = stringResource(CommonStrings.action_continue),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description), onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
submitText = stringResource(CommonStrings.action_continue), onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) },
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, destructiveSubmit = true,
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }, )
destructiveSubmit = true, }
) is ConfirmingModifyingAdmins -> {
} ConfirmationDialog(
is RoomMember.Role.Admin -> { title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
ConfirmationDialog( content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, )
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
)
}
// No confirmation needed for Moderator or User roles
else -> Unit
}
} }
} }
}, },

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2025 Element Creations 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.roles
import io.element.android.libraries.architecture.AsyncAction
data object ConfirmingModifyingAdmins : AsyncAction.Confirming

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2025 Element Creations 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.roles
import io.element.android.libraries.architecture.AsyncAction
data object ConfirmingModifyingOwners : AsyncAction.Confirming

View file

@ -8,9 +8,6 @@
package io.element.android.features.rolesandpermissions.impl.roles package io.element.android.features.rolesandpermissions.impl.roles
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -31,6 +28,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.previewutils.room.aRoomMemberList import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -43,9 +41,7 @@ class ChangeRolesPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createChangeRolesPresenter() val presenter = createChangeRolesPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(role).isEqualTo(RoomMember.Role.Admin) assertThat(role).isEqualTo(RoomMember.Role.Admin)
assertThat(query).isNull() assertThat(query).isNull()
@ -65,16 +61,14 @@ class ChangeRolesPresenterTest {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
} }
} }
@Test @Test
fun `present - canChangeRole of users with lower power level unless they are owners`() = runTest { fun `present - canChangeRole of users with lower power level unless they are owners - privilegedCreatorRole is true`() = runTest {
val creatorUserId = UserId("@creator:matrix.org") val creatorUserId = UserId("@creator:matrix.org")
val superAdminUserId = UserId("@super_admin:matrix.org") val superAdminUserId = UserId("@super_admin:matrix.org")
@ -82,6 +76,7 @@ class ChangeRolesPresenterTest {
// User is a creator, so they can change roles of other members. So is `creatorUserId`. // User is a creator, so they can change roles of other members. So is `creatorUserId`.
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
privilegedCreatorRole = true,
roomCreators = listOf(sessionId, creatorUserId), roomCreators = listOf(sessionId, creatorUserId),
roomPowerLevels = RoomPowerLevels( roomPowerLevels = RoomPowerLevels(
defaultRoomPowerLevelValues(), defaultRoomPowerLevelValues(),
@ -99,16 +94,14 @@ class ChangeRolesPresenterTest {
val roomMemberList = aRoomMemberList() + listOf( val roomMemberList = aRoomMemberList() + listOf(
// Owner - superadmin // Owner - superadmin
aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = true)), aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = false)),
// Owner - creator // Owner - creator
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true)) aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
) )
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList())) givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
awaitItem().run { awaitItem().run {
assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin
@ -118,6 +111,53 @@ class ChangeRolesPresenterTest {
} }
} }
@Test
fun `present - canChangeRole of users with lower power level unless they are owners - privilegedCreatorRole is false`() = runTest {
val creatorUserId = UserId("@creator:matrix.org")
val superAdminUserId = UserId("@super_admin:matrix.org")
val room = FakeJoinedRoom().apply {
// User is a creator, so they can change roles of other members. So is `creatorUserId`.
givenRoomInfo(
aRoomInfo(
privilegedCreatorRole = false,
roomCreators = listOf(sessionId, creatorUserId),
roomPowerLevels = RoomPowerLevels(
defaultRoomPowerLevelValues(),
users = persistentMapOf(
// Creator is an admin
sessionId to RoomMember.Role.Admin.powerLevel,
creatorUserId to RoomMember.Role.Admin.powerLevel,
// bob is Admin
A_USER_ID_2 to RoomMember.Role.Admin.powerLevel,
// carol is Moderator
A_USER_ID_3 to RoomMember.Role.Moderator.powerLevel,
// super_admin is Owner - Superadmin
superAdminUserId to RoomMember.Role.Owner(isCreator = false).powerLevel,
)
)
)
)
val roomMemberList = aRoomMemberList() + listOf(
// Owner - superadmin
aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = false)),
// Owner - creator
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
)
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList()))
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
skipItems(1)
awaitItem().run {
assertThat(canChangeMemberRole(A_USER_ID_2)).isFalse() // Creator cannot update Admin in this case
assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator
assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner
}
}
}
@Test @Test
fun `present - when modifying admins, creators are displayed too`() = runTest { fun `present - when modifying admins, creators are displayed too`() = runTest {
val room = FakeJoinedRoom().apply { val room = FakeJoinedRoom().apply {
@ -129,9 +169,7 @@ class ChangeRolesPresenterTest {
givenRoomMembersState(RoomMembersState.Ready(memberList)) givenRoomMembersState(RoomMembersState.Ready(memberList))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
awaitItem().searchResults.run { awaitItem().searchResults.run {
assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java)
@ -149,9 +187,7 @@ class ChangeRolesPresenterTest {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive) initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
@ -168,9 +204,7 @@ class ChangeRolesPresenterTest {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8) assertThat(initialResults?.members).hasSize(8)
@ -194,9 +228,7 @@ class ChangeRolesPresenterTest {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8) assertThat(initialResults?.members).hasSize(8)
@ -221,9 +253,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -243,9 +273,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
@ -272,9 +300,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
@ -292,9 +318,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
@ -318,9 +342,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
@ -349,16 +371,14 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(2) 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(ConfirmingModifyingAdmins)
confirmingState.eventSink(ChangeRolesEvent.Save) confirmingState.eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
@ -372,9 +392,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
} }
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -383,7 +401,7 @@ class ChangeRolesPresenterTest {
awaitItem().eventSink(ChangeRolesEvent.Save) awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(confirmingState.savingState).isEqualTo(ConfirmingModifyingAdmins)
confirmingState.eventSink(ChangeRolesEvent.CloseDialog) confirmingState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
@ -405,9 +423,7 @@ class ChangeRolesPresenterTest {
room = room, room = room,
analyticsService = analyticsService analyticsService = analyticsService
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -442,9 +458,7 @@ class ChangeRolesPresenterTest {
room = room, room = room,
analyticsService = analyticsService analyticsService = analyticsService
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -477,9 +491,7 @@ class ChangeRolesPresenterTest {
room = room, room = room,
analyticsService = analyticsService analyticsService = analyticsService
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)
@ -504,9 +516,7 @@ class ChangeRolesPresenterTest {
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID)))
} }
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room) val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) assertThat(initialState.selectedUsers).hasSize(1)

View file

@ -50,7 +50,6 @@ class ChangeRolesViewTest {
), ),
) )
}.exceptionOrNull() }.exceptionOrNull()
assertThat(exception).isNotNull() assertThat(exception).isNotNull()
} }
@ -63,9 +62,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive) eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
} }
@ -78,9 +75,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit)) eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
} }
@ -93,9 +88,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressBack() rule.pressBack()
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit)) eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
} }
@ -108,9 +101,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_save) rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save)) eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
} }
@ -123,9 +114,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_save) rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""))) eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
} }
@ -139,9 +128,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ChangeRolesEvent.Exit) eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
} }
@ -155,26 +142,22 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }
@Test @Test
fun `save confirmation dialog - submit saves the changes`() { fun `save admins confirmation dialog - submit saves the changes`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>() val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = aChangeRolesState( state = aChangeRolesState(
role = RoomMember.Role.Admin, role = RoomMember.Role.Admin,
isSearchActive = true, isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams, savingState = ConfirmingModifyingAdmins,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ChangeRolesEvent.Save) eventsRecorder.assertSingle(ChangeRolesEvent.Save)
} }
@ -185,30 +168,41 @@ class ChangeRolesViewTest {
state = aChangeRolesState( state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = false), role = RoomMember.Role.Owner(isCreator = false),
isSearchActive = true, isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams, savingState = ConfirmingModifyingOwners,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_continue) rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ChangeRolesEvent.Save) eventsRecorder.assertSingle(ChangeRolesEvent.Save)
} }
@Test @Test
fun `save confirmation dialog - cancel removes the dialog`() { fun `save admins confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>() val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = aChangeRolesState( state = aChangeRolesState(
role = RoomMember.Role.Admin, role = RoomMember.Role.Admin,
isSearchActive = true, isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams, savingState = ConfirmingModifyingAdmins,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
}
@Test
fun `save owners confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = false),
isSearchActive = true,
savingState = ConfirmingModifyingOwners,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }
@ -222,9 +216,7 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }

View file

@ -205,6 +205,7 @@ class RoomDetailsPresenter(
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
isTombstoned = roomInfo.successorRoom != null, isTombstoned = roomInfo.successorRoom != null,
showDebugInfo = isDeveloperModeEnabled, showDebugInfo = isDeveloperModeEnabled,
roomVersion = roomInfo.roomVersion,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -49,6 +49,7 @@ data class RoomDetailsState(
val canReportRoom: Boolean, val canReportRoom: Boolean,
val isTombstoned: Boolean, val isTombstoned: Boolean,
val showDebugInfo: Boolean, val showDebugInfo: Boolean,
val roomVersion: String?,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) { ) {
val roomBadges = buildList { val roomBadges = buildList {

View file

@ -146,6 +146,7 @@ fun aRoomDetailsState(
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,
roomVersion = "12",
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -264,6 +264,7 @@ fun RoomDetailsView(
if (state.showDebugInfo) { if (state.showDebugInfo) {
DebugInfoSection( DebugInfoSection(
roomId = state.roomId, roomId = state.roomId,
roomVersion = state.roomVersion,
) )
} }
} }
@ -714,7 +715,10 @@ private fun OtherActionsSection(
} }
@Composable @Composable
private fun DebugInfoSection(roomId: RoomId) { private fun DebugInfoSection(
roomId: RoomId,
roomVersion: String?,
) {
val context = LocalContext.current val context = LocalContext.current
PreferenceCategory(showTopDivider = true) { PreferenceCategory(showTopDivider = true) {
ListItem( ListItem(
@ -737,6 +741,19 @@ private fun DebugInfoSection(roomId: RoomId) {
) )
}, },
) )
ListItem(
headlineContent = {
Text("Room version")
},
supportingContent = {
Text(
text = roomVersion ?: "Unknown",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
)
} }
} }

View file

@ -53,7 +53,7 @@ fun aRoomInfo(
notificationCount: Long = 0, notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null, userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
roomPowerLevels: RoomPowerLevels = RoomPowerLevels( roomPowerLevels: RoomPowerLevels? = RoomPowerLevels(
values = defaultRoomPowerLevelValues(), values = defaultRoomPowerLevelValues(),
users = persistentMapOf(), users = persistentMapOf(),
), ),

View file

@ -23,12 +23,12 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
/** /**
* Returns the role of the user in the room. * Returns the role of the user in the room.
* If the user is a creator, returns [RoomMember.Role.Owner]. * If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner].
* Otherwise, checks the power levels and returns the corresponding role. * Otherwise, checks the power levels and returns the corresponding role.
* If no specific power level is set for the user, defaults to [RoomMember.Role.User]. * If no specific power level is set for the user, defaults to [RoomMember.Role.User].
*/ */
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role { fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
return if (creators.contains(userId)) { return if (privilegedCreatorRole && creators.contains(userId)) {
RoomMember.Role.Owner(isCreator = true) RoomMember.Role.Owner(isCreator = true)
} else { } else {
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.model
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import kotlinx.collections.immutable.toImmutableMap
import org.junit.Test
class RoomInfoExtensionTest {
@Test
fun `roleOf returns Owner for creator with privilegedCreatorRole true`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = true,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Owner(isCreator = true))
}
@Test
fun `roleOf returns User for not creator with privilegedCreatorRole true`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = true,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns User for creator with privilegedCreatorRole false`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns role from the power level`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomPowerLevels = RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
users = mapOf(
A_USER_ID to 100L, // Admin
A_USER_ID_2 to 50L, // Moderator
A_USER_ID_3 to 0L, // User
).toImmutableMap(),
),
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Admin)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.Moderator)
assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns User when the power level is null`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomPowerLevels = null,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User)
assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User)
}
}

View file

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

View file

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