Force last owner of a room to pass ownership when leaving (#5094)
* Move `ChangeRoles*` classes to their own module so they can be shared * Hook the change roles screen to the leave room action, add confirmation dialogs * Use enum instead of sealed interface for `ChangeRoomMemberRolesListType` * Try to improve communications between nodes * refactor (leave room) : makes sure to expose only necessary code from api module * Add `:libraries:previewutils` module to share some test fixtures used for UI previews * Update screenshots --------- Co-authored-by: ElementBot <android@element.io> Co-authored-by: ganfra <francoisg@matrix.org>
This commit is contained in:
parent
a87bbdd91c
commit
955263bee1
112 changed files with 1337 additions and 513 deletions
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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.changeroommemberroles.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface ChangeRolesEvent {
|
||||
data object ToggleSearchActive : ChangeRolesEvent
|
||||
data class QueryChanged(val query: String?) : ChangeRolesEvent
|
||||
data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
|
||||
data object Save : ChangeRolesEvent
|
||||
data object Exit : ChangeRolesEvent
|
||||
data object CancelExit : ChangeRolesEvent
|
||||
data object ClearError : ChangeRolesEvent
|
||||
data object CancelSave : ChangeRolesEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.appyx.launchMolecule
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class ChangeRolesNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: ChangeRolesPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
val listType: ChangeRoomMemberRolesListType,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter = presenterFactory.run {
|
||||
val role = when (inputs.listType) {
|
||||
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
|
||||
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
|
||||
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
|
||||
}
|
||||
create(role)
|
||||
}
|
||||
|
||||
private val stateFlow = launchMolecule { presenter.present() }
|
||||
|
||||
suspend fun waitForRoleChanged() {
|
||||
stateFlow.first { it.savingState.isSuccess() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state by stateFlow.collectAsState()
|
||||
ChangeRolesView(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
navigateUp = this::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.RoomModeration
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.roleOf
|
||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChangeRolesPresenter @AssistedInject constructor(
|
||||
@Assisted private val role: RoomMember.Role,
|
||||
private val room: JoinedRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : Presenter<ChangeRolesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(role: RoomMember.Role): ChangeRolesPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ChangeRolesState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
|
||||
var query by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var searchResults by remember {
|
||||
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
|
||||
}
|
||||
val selectedUsers = remember {
|
||||
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
|
||||
}
|
||||
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val usersWithRole = produceState(initialValue = persistentListOf()) {
|
||||
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
|
||||
.onEach { users ->
|
||||
val previous: PersistentList<MatrixUser> = value
|
||||
value = users.toPersistentList()
|
||||
// Users who were selected but didn't have the role, so their role change was pending
|
||||
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
|
||||
// Users who no longer have the role
|
||||
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
|
||||
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
val roomMemberState by room.membersStateFlow.collectAsState()
|
||||
|
||||
// Update search results for every query change
|
||||
LaunchedEffect(query, roomMemberState) {
|
||||
val results = dataSource
|
||||
.search(query.orEmpty())
|
||||
.groupedByRole()
|
||||
|
||||
searchResults = if (results.isEmpty()) {
|
||||
SearchBarResultState.NoResultsFound()
|
||||
} else {
|
||||
SearchBarResultState.Results(results)
|
||||
}
|
||||
}
|
||||
|
||||
val hasPendingChanges = usersWithRole.value != selectedUsers.value
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
fun canChangeMemberRole(userId: UserId): Boolean {
|
||||
// This is used to group the
|
||||
val currentUserRole = roomInfo.roleOf(room.sessionId)
|
||||
val otherUserRole = roomInfo.roleOf(userId)
|
||||
return currentUserRole.powerLevel > otherUserRole.powerLevel
|
||||
}
|
||||
|
||||
fun handleEvent(event: ChangeRolesEvent) {
|
||||
when (event) {
|
||||
is ChangeRolesEvent.ToggleSearchActive -> {
|
||||
searchActive = !searchActive
|
||||
}
|
||||
is ChangeRolesEvent.QueryChanged -> {
|
||||
query = event.query
|
||||
}
|
||||
is ChangeRolesEvent.UserSelectionToggled -> {
|
||||
val newList = selectedUsers.value.toMutableList()
|
||||
val index = newList.indexOfFirst { it.userId == event.matrixUser.userId }
|
||||
if (index >= 0) {
|
||||
newList.removeAt(index)
|
||||
} else {
|
||||
newList.add(event.matrixUser)
|
||||
}
|
||||
selectedUsers.value = newList.toImmutableList()
|
||||
}
|
||||
is ChangeRolesEvent.Save -> {
|
||||
val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin
|
||||
val isModifyingAdmins = role == RoomMember.Role.Admin
|
||||
val hasChanges = selectedUsers != usersWithRole
|
||||
val isConfirming = saveState.value.isConfirming()
|
||||
val modifyingOwners = role is RoomMember.Role.Owner
|
||||
|
||||
val needsConfirmation = (modifyingOwners || currentUserIsAdmin && isModifyingAdmins) && hasChanges && !isConfirming
|
||||
|
||||
when {
|
||||
needsConfirmation -> {
|
||||
// Confirm modifying users
|
||||
saveState.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
!saveState.value.isLoading() -> {
|
||||
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChangeRolesEvent.ClearError -> {
|
||||
saveState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is ChangeRolesEvent.Exit -> {
|
||||
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) {
|
||||
// Has pending changes, confirm exit
|
||||
AsyncAction.ConfirmingNoParams
|
||||
} else {
|
||||
// No pending changes, exit immediately
|
||||
AsyncAction.Success(Unit)
|
||||
}
|
||||
}
|
||||
is ChangeRolesEvent.CancelExit -> {
|
||||
exitState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is ChangeRolesEvent.CancelSave -> {
|
||||
saveState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
return ChangeRolesState(
|
||||
role = role,
|
||||
query = query,
|
||||
isSearchActive = searchActive,
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers.value,
|
||||
hasPendingChanges = hasPendingChanges,
|
||||
exitState = exitState.value,
|
||||
savingState = saveState.value,
|
||||
canChangeMemberRole = ::canChangeMemberRole,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<RoomMember>.groupedByRole(): MembersByRole {
|
||||
val groupedMembers = MembersByRole(this)
|
||||
return MembersByRole(
|
||||
owners = groupedMembers.owners.sorted(),
|
||||
admins = groupedMembers.admins.sorted(),
|
||||
moderators = groupedMembers.moderators.sorted(),
|
||||
members = groupedMembers.members.sorted(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.save(
|
||||
usersWithRole: ImmutableList<MatrixUser>,
|
||||
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
|
||||
saveState: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
saveState.value = AsyncAction.Loading
|
||||
|
||||
val toAdd = selectedUsers.value - usersWithRole
|
||||
val toRemove = usersWithRole - selectedUsers.value
|
||||
|
||||
val changes: List<UserRoleChange> = buildList {
|
||||
for (selectedUser in toAdd) {
|
||||
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole()))
|
||||
add(UserRoleChange(selectedUser.userId, role))
|
||||
}
|
||||
for (selectedUser in toRemove) {
|
||||
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
|
||||
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
|
||||
}
|
||||
}
|
||||
|
||||
room.updateUsersRoles(changes)
|
||||
.onFailure {
|
||||
saveState.value = AsyncAction.Failure(it)
|
||||
}
|
||||
.onSuccess {
|
||||
saveState.value = AsyncAction.Success(Unit)
|
||||
// Asynchronously reload the room members
|
||||
launch { room.updateMembers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
|
||||
is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
|
||||
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
|
||||
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
|
||||
RoomMember.Role.User -> RoomModeration.Role.User
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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.changeroommemberroles.impl
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class ChangeRolesState(
|
||||
val role: RoomMember.Role,
|
||||
val query: String?,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<MembersByRole>,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val hasPendingChanges: Boolean,
|
||||
val exitState: AsyncAction<Unit>,
|
||||
val savingState: AsyncAction<Unit>,
|
||||
val canChangeMemberRole: (UserId) -> Boolean,
|
||||
val eventSink: (ChangeRolesEvent) -> Unit,
|
||||
)
|
||||
|
||||
data class MembersByRole(
|
||||
val owners: ImmutableList<RoomMember>,
|
||||
val admins: ImmutableList<RoomMember>,
|
||||
val moderators: ImmutableList<RoomMember>,
|
||||
val members: ImmutableList<RoomMember>,
|
||||
) {
|
||||
constructor(members: List<RoomMember>) : this(
|
||||
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
|
||||
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
|
||||
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
|
||||
members = members.filter { it.role == RoomMember.Role.User }.sorted(),
|
||||
)
|
||||
|
||||
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
|
||||
}
|
||||
|
||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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.changeroommemberroles.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.previewutils.room.aRoomMember
|
||||
import io.element.android.libraries.previewutils.room.aRoomMemberList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||
override val values: Sequence<ChangeRolesState>
|
||||
get() = sequenceOf(
|
||||
aChangeRolesState(),
|
||||
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.Moderator),
|
||||
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
|
||||
aChangeRolesStateWithSelectedUsers(),
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
|
||||
),
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
query = "Alice",
|
||||
isSearchActive = true,
|
||||
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
|
||||
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
|
||||
),
|
||||
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.ConfirmingNoParams),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
|
||||
aChangeRolesStateWithOwners(),
|
||||
aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aChangeRolesState(
|
||||
role: RoomMember.Role = RoomMember.Role.Admin,
|
||||
query: String? = null,
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
|
||||
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
hasPendingChanges: Boolean = false,
|
||||
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
canRemoveMember: (UserId) -> Boolean = { true },
|
||||
eventSink: (ChangeRolesEvent) -> Unit = {},
|
||||
) = ChangeRolesState(
|
||||
role = role,
|
||||
query = query,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers,
|
||||
hasPendingChanges = hasPendingChanges,
|
||||
exitState = exitState,
|
||||
savingState = savingState,
|
||||
canChangeMemberRole = canRemoveMember,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(
|
||||
MembersByRole(
|
||||
members = aRoomMemberList().mapIndexed { index, roomMember ->
|
||||
if (index % 2 == 0) {
|
||||
roomMember.copy(membership = RoomMembershipState.INVITE)
|
||||
} else {
|
||||
roomMember
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
hasPendingChanges = true,
|
||||
canRemoveMember = { it != UserId("@alice:server.org") },
|
||||
)
|
||||
|
||||
internal fun aChangeRolesStateWithOwners() = aChangeRolesState(
|
||||
role = RoomMember.Role.Admin,
|
||||
searchResults = SearchBarResultState.Results(
|
||||
MembersByRole(
|
||||
members = persistentListOf(
|
||||
aRoomMember(
|
||||
userId = UserId("@alice:server.org"),
|
||||
displayName = "Alice",
|
||||
role = RoomMember.Role.Owner(isCreator = true),
|
||||
),
|
||||
aRoomMember(
|
||||
userId = UserId("@bob:server.org"),
|
||||
displayName = "Bob",
|
||||
role = RoomMember.Role.Owner(isCreator = false),
|
||||
),
|
||||
aRoomMember(
|
||||
userId = UserId("@carol:server.org"),
|
||||
displayName = "Carol",
|
||||
role = RoomMember.Role.Admin,
|
||||
),
|
||||
aRoomMember(
|
||||
userId = UserId("@david:server.org"),
|
||||
displayName = "David",
|
||||
role = RoomMember.Role.User,
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
canRemoveMember = { userId ->
|
||||
when (userId) {
|
||||
UserId("@alice:server.org") -> false // Owner - creator
|
||||
UserId("@bob:server.org") -> false // Owner - super admin
|
||||
UserId("@carol:server.org") -> true // Admin
|
||||
UserId("@david:server.org") -> true // User
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
selectedUsers = persistentListOf(
|
||||
aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
|
||||
aMatrixUser(id = "@bob:server.org", displayName = "Bob"),
|
||||
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
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.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
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.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Checkbox
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangeRolesView(
|
||||
state: ChangeRolesState,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val latestNavigateUp by rememberUpdatedState(newValue = navigateUp)
|
||||
BackHandler(enabled = !state.isSearchActive) {
|
||||
state.eventSink(ChangeRolesEvent.Exit)
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding(),
|
||||
topBar = {
|
||||
AnimatedVisibility(visible = !state.isSearchActive) {
|
||||
TopAppBar(
|
||||
titleStr = when (state.role) {
|
||||
is RoomMember.Role.Owner -> stringResource(R.string.screen_room_change_role_owners_title)
|
||||
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title)
|
||||
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title)
|
||||
RoomMember.Role.User -> error("This should never be reached")
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_save),
|
||||
enabled = state.hasPendingChanges,
|
||||
onClick = { state.eventSink(ChangeRolesEvent.Save) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
SearchBar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
|
||||
query = state.query.orEmpty(),
|
||||
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
|
||||
active = state.isSearchActive,
|
||||
onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) },
|
||||
resultState = state.searchResults,
|
||||
) { members ->
|
||||
SearchResultsList(
|
||||
currentRole = state.role,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = members,
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
||||
selectedUsersList = {},
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !state.isSearchActive,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column {
|
||||
SearchResultsList(
|
||||
currentRole = state.role,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
||||
selectedUsersList = { users ->
|
||||
SelectedUsersRowList(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
selectedUsers = users,
|
||||
onUserRemove = {
|
||||
state.eventSink(ChangeRolesEvent.UserSelectionToggled(it))
|
||||
},
|
||||
canDeselect = { state.canChangeMemberRole(it.userId) },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val asyncIndicatorState = rememberAsyncIndicatorState()
|
||||
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
|
||||
|
||||
AsyncActionView(
|
||||
async = state.exitState,
|
||||
onSuccess = { latestNavigateUp() },
|
||||
confirmationDialog = {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
|
||||
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
|
||||
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
|
||||
)
|
||||
},
|
||||
onErrorDismiss = { /* Cannot happen */ },
|
||||
)
|
||||
|
||||
when (state.savingState) {
|
||||
is AsyncAction.Confirming -> {
|
||||
when (state.role) {
|
||||
is RoomMember.Role.Owner -> {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
|
||||
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
|
||||
submitText = stringResource(CommonStrings.action_continue),
|
||||
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) },
|
||||
destructiveSubmit = true,
|
||||
)
|
||||
}
|
||||
is RoomMember.Role.Admin -> {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
|
||||
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
|
||||
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
|
||||
)
|
||||
}
|
||||
else -> Unit // No confirmation needed for Moderator or User roles
|
||||
}
|
||||
}
|
||||
is AsyncAction.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(CommonStrings.error_unknown),
|
||||
onSubmit = { state.eventSink(ChangeRolesEvent.ClearError) }
|
||||
)
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
LaunchedEffect(state.savingState) {
|
||||
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
|
||||
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultsList(
|
||||
currentRole: RoomMember.Role,
|
||||
searchResults: MembersByRole,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
canRemoveMember: (UserId) -> Boolean,
|
||||
onToggleSelection: (RoomMember) -> Unit,
|
||||
lazyListState: LazyListState,
|
||||
selectedUsersList: @Composable (ImmutableList<MatrixUser>) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
) {
|
||||
item {
|
||||
selectedUsersList(selectedUsers)
|
||||
}
|
||||
if (searchResults.owners.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_owners)) }
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(R.string.screen_room_change_role_moderators_owner_section_footer),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
items(searchResults.owners, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onToggleSelection = onToggleSelection,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchResults.admins.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
|
||||
// Add a footer for the admin section in change role to moderator screen
|
||||
if (currentRole == RoomMember.Role.Moderator) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(searchResults.admins, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onToggleSelection = onToggleSelection,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchResults.moderators.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_moderators)) }
|
||||
items(searchResults.moderators, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onToggleSelection = onToggleSelection,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchResults.members.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_member_list_mode_members)) }
|
||||
items(searchResults.members, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onToggleSelection = onToggleSelection,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListSectionHeader(text: String) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListMemberItem(
|
||||
roomMember: RoomMember,
|
||||
canRemoveMember: (UserId) -> Boolean,
|
||||
onToggleSelection: (RoomMember) -> Unit,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
) {
|
||||
val canToggle = canRemoveMember(roomMember.userId)
|
||||
val trailingContent: @Composable (() -> Unit) = {
|
||||
if (canToggle) {
|
||||
Checkbox(
|
||||
checked = selectedUsers.any { it.userId == roomMember.userId },
|
||||
onCheckedChange = { onToggleSelection(roomMember) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
MemberRow(
|
||||
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
|
||||
avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
|
||||
name = roomMember.getBestName(),
|
||||
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
|
||||
isPending = roomMember.membership == RoomMembershipState.INVITE,
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberRow(
|
||||
avatarData: AvatarData,
|
||||
name: String,
|
||||
userId: String?,
|
||||
isPending: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
// Invitation pending marker
|
||||
if (isPending) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResource(id = R.string.screen_room_member_list_pending_header_title),
|
||||
style = ElementTheme.typography.fontBodySmRegular.copy(fontStyle = FontStyle.Italic),
|
||||
color = ElementTheme.colors.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
// Id
|
||||
userId?.let {
|
||||
Text(
|
||||
text = userId,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) {
|
||||
ElementPreview {
|
||||
ChangeRolesView(
|
||||
state = state,
|
||||
navigateUp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PendingMemberRowWithLongNamePreview() {
|
||||
ElementPreview(
|
||||
drawableFallbackForImages = CommonDrawables.sample_avatar,
|
||||
) {
|
||||
MemberRow(
|
||||
avatarData = AvatarData("userId", "A very long name that should be truncated", "https://example.com/avatar.png", AvatarSize.UserListItem),
|
||||
name = "A very long name that should be truncated",
|
||||
userId = "@alice:matrix.org",
|
||||
isPending = true,
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.di.RoomComponentFactory
|
||||
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
|
||||
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DaggerComponentOwner
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class ChangeRoomMemberRolesRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
roomComponentFactory: RoomComponentFactory,
|
||||
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget.Root),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
), DaggerComponentOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
val joinedRoom: JoinedRoom,
|
||||
val listType: ChangeRoomMemberRolesListType,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
override val daggerComponent = roomComponentFactory.create(inputs.joinedRoom)
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
createNode<ChangeRolesNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(modifier = modifier, navModel = navModel)
|
||||
}
|
||||
|
||||
override val roomId: RoomId = inputs.joinedRoom.roomId
|
||||
|
||||
override suspend fun waitForRoleChanged() {
|
||||
waitForChildAttached<ChangeRolesNode>().waitForRoleChanged()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
|
||||
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultChangeRoomMemberRolesEntyPoint @Inject constructor() : ChangeRoomMemberRolesEntryPoint {
|
||||
override fun builder(parentNode: Node, buildContext: BuildContext): ChangeRoomMemberRolesEntryPoint.Builder {
|
||||
return object : ChangeRoomMemberRolesEntryPoint.Builder {
|
||||
private lateinit var changeRoomMemberRolesListType: ChangeRoomMemberRolesListType
|
||||
private lateinit var room: JoinedRoom
|
||||
|
||||
override fun room(room: JoinedRoom): ChangeRoomMemberRolesEntryPoint.Builder {
|
||||
this.room = room
|
||||
return this
|
||||
}
|
||||
|
||||
override fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): ChangeRoomMemberRolesEntryPoint.Builder {
|
||||
this.changeRoomMemberRolesListType = changeRoomMemberRolesListType
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<ChangeRoomMemberRolesRootNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(
|
||||
ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = changeRoomMemberRolesListType),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomMemberListDataSource @Inject constructor(
|
||||
private val room: BaseRoom,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
|
||||
val roomMembersState = room.membersStateFlow.value
|
||||
val activeRoomMembers = roomMembersState.roomMembers()
|
||||
?.filter { it.membership.isActive() }
|
||||
.orEmpty()
|
||||
val filteredMembers = if (query.isBlank()) {
|
||||
activeRoomMembers
|
||||
} else {
|
||||
activeRoomMembers.filter { member ->
|
||||
member.userId.value.contains(query, ignoreCase = true) ||
|
||||
member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||
}
|
||||
}
|
||||
filteredMembers
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_room_change_permissions_administrators">"Admins only"</string>
|
||||
<string name="screen_room_change_permissions_ban_people">"Ban people"</string>
|
||||
<string name="screen_room_change_permissions_delete_messages">"Remove messages"</string>
|
||||
<string name="screen_room_change_permissions_everyone">"Everyone"</string>
|
||||
<string name="screen_room_change_permissions_invite_people">"Invite people and accept requests to join"</string>
|
||||
<string name="screen_room_change_permissions_member_moderation">"Member moderation"</string>
|
||||
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
|
||||
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
|
||||
<string name="screen_room_change_permissions_remove_people">"Remove people 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_details">"Room details"</string>
|
||||
<string name="screen_room_change_permissions_room_name">"Change room name"</string>
|
||||
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string>
|
||||
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
|
||||
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
|
||||
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
|
||||
<string name="screen_room_change_role_confirm_add_admin_title">"Add Admin?"</string>
|
||||
<string name="screen_room_change_role_confirm_change_owners_description">"You will not be able to undo this action. You are transferring the ownership to the selected users. Once you leave this will be permanent."</string>
|
||||
<string name="screen_room_change_role_confirm_change_owners_title">"Transfer ownership?"</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
|
||||
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
|
||||
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
|
||||
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
|
||||
<string name="screen_room_change_role_moderators_owner_section_footer">"Owners automatically have admin privileges."</string>
|
||||
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
|
||||
<string name="screen_room_change_role_owners_title">"Choose Owners"</string>
|
||||
<string name="screen_room_change_role_section_administrators">"Admins"</string>
|
||||
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
|
||||
<string name="screen_room_change_role_section_users">"Members"</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
|
||||
<string name="screen_room_member_list_banned_empty">"There are no banned users in this room."</string>
|
||||
<plurals name="screen_room_member_list_header_title">
|
||||
<item quantity="one">"%1$d person"</item>
|
||||
<item quantity="other">"%1$d people"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban from room"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
|
||||
<string name="screen_room_member_list_manage_member_unban_action">"Unban"</string>
|
||||
<string name="screen_room_member_list_manage_member_unban_message">"They will be able to join this room again if invited."</string>
|
||||
<string name="screen_room_member_list_manage_member_unban_title">"Unban user"</string>
|
||||
<string name="screen_room_member_list_mode_banned">"Banned"</string>
|
||||
<string name="screen_room_member_list_mode_members">"Members"</string>
|
||||
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
|
||||
<string name="screen_room_member_list_role_administrator">"Admin"</string>
|
||||
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
|
||||
<string name="screen_room_member_list_role_owner">"Owner"</string>
|
||||
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
|
||||
<string name="screen_room_member_list_unbanning_user">"Unbanning %1$s"</string>
|
||||
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
|
||||
<string name="screen_room_roles_and_permissions_admins_and_owners">"Admins and owners"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
|
||||
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
|
||||
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
|
||||
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
|
||||
<string name="screen_room_roles_and_permissions_owners">"Owners"</string>
|
||||
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
|
||||
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
|
||||
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Once you reset permissions, you will lose the current settings."</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_room_details">"Room details"</string>
|
||||
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.RoomModeration
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
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.FakeBaseRoom
|
||||
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.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
|
||||
import io.element.android.libraries.previewutils.room.aRoomMemberList
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.collections.plus
|
||||
|
||||
class ChangeRolesPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createChangeRolesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
with(awaitItem()) {
|
||||
assertThat(role).isEqualTo(RoomMember.Role.Admin)
|
||||
assertThat(query).isNull()
|
||||
assertThat(isSearchActive).isFalse()
|
||||
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
assertThat(selectedUsers).isEmpty()
|
||||
assertThat(hasPendingChanges).isFalse()
|
||||
assertThat(exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
assertThat(savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial results are loaded automatically`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - canChangeRole of users with lower power level unless they are owners`() = 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(
|
||||
roomCreators = listOf(sessionId, creatorUserId),
|
||||
roomPowerLevels = RoomPowerLevels(
|
||||
defaultRoomPowerLevelValues(),
|
||||
users = persistentMapOf(
|
||||
// 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 = true)),
|
||||
// Owner - creator
|
||||
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
|
||||
)
|
||||
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toPersistentList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().run {
|
||||
assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin
|
||||
assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator
|
||||
assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when modifying admins, creators are displayed too`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
val creatorUserId = UserId("@creator:matrix.org")
|
||||
val memberList = aRoomMemberList()
|
||||
.plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId))
|
||||
.toPersistentList()
|
||||
givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId)))
|
||||
givenRoomMembersState(RoomMembersState.Ready(memberList))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().searchResults.run {
|
||||
assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
val results = (this as SearchBarResultState.Results).results
|
||||
assertThat(results.admins).isNotEmpty()
|
||||
assertThat(results.owners).isNotEmpty()
|
||||
assertThat(results.owners.last().role).isEqualTo(RoomMember.Role.Owner(isCreator = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ToggleSearchActive changes the value`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isTrue()
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - QueryChanged produces new results`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(initialResults?.members).hasSize(8)
|
||||
assertThat(initialResults?.moderators).hasSize(1)
|
||||
assertThat(initialResults?.admins).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
|
||||
skipItems(1)
|
||||
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(searchResults?.admins).hasSize(1)
|
||||
assertThat(searchResults?.moderators).isEmpty()
|
||||
assertThat(searchResults?.members).isEmpty()
|
||||
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - changes in the room members produce new results`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(initialResults?.members).hasSize(8)
|
||||
assertThat(initialResults?.moderators).hasSize(1)
|
||||
assertThat(initialResults?.admins).hasSize(1)
|
||||
|
||||
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
|
||||
skipItems(1)
|
||||
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(searchResults?.admins).hasSize(1)
|
||||
assertThat(searchResults?.moderators).isEmpty()
|
||||
assertThat(searchResults?.members).isEmpty()
|
||||
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(2)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(2)
|
||||
assertThat(hasPendingChanges).isTrue()
|
||||
}
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(1)
|
||||
assertThat(hasPendingChanges).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Exit will display success if no pending changes`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelExit will remove exit confirmation`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Exit)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.exitState).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.CancelExit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
val updatedState = awaitItem()
|
||||
assertThat(updatedState.hasPendingChanges).isTrue()
|
||||
skipItems(1)
|
||||
|
||||
updatedState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
|
||||
updatedState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will display a confirmation when adding admins`() = runTest {
|
||||
val room = FakeJoinedRoom(
|
||||
updateUserRoleResult = { Result.success(Unit) },
|
||||
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
|
||||
).apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.Save)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelSave will remove the confirmation dialog`() = runTest {
|
||||
val room = FakeJoinedRoom().apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.CancelSave)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will just save the data for moderators`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val room = FakeJoinedRoom(
|
||||
updateUserRoleResult = { Result.success(Unit) },
|
||||
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
|
||||
).apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(
|
||||
role = RoomMember.Role.Moderator,
|
||||
room = room,
|
||||
analyticsService = analyticsService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will ask for confirmation before assigning new owners`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val room = FakeJoinedRoom(
|
||||
updateUserRoleResult = { Result.success(Unit) },
|
||||
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
|
||||
).apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(
|
||||
aRoomInfo(
|
||||
roomCreators = listOf(sessionId),
|
||||
roomPowerLevels = roomPowerLevelsWithRoles(
|
||||
A_USER_ID to RoomMember.Role.Owner(isCreator = false),
|
||||
A_USER_ID_2 to RoomMember.Role.Admin,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(
|
||||
role = RoomMember.Role.Owner(isCreator = false),
|
||||
room = room,
|
||||
analyticsService = analyticsService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
|
||||
assertThat(awaitItem().savingState.isConfirming()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val room = FakeJoinedRoom(
|
||||
updateUserRoleResult = { Result.success(Unit) },
|
||||
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
|
||||
).apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(
|
||||
aRoomInfo(
|
||||
roomCreators = listOf(sessionId),
|
||||
roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2)
|
||||
)
|
||||
)
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(
|
||||
role = RoomMember.Role.Admin,
|
||||
room = room,
|
||||
analyticsService = analyticsService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save can handle failures and ClearError clears them`() = runTest {
|
||||
val room = FakeJoinedRoom(
|
||||
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
|
||||
).apply {
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
skipItems(1)
|
||||
val failedState = awaitItem()
|
||||
assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
|
||||
failedState.eventSink(ChangeRolesEvent.ClearError)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roomPowerLevelsWithRole(
|
||||
role: RoomMember.Role,
|
||||
userId: UserId = A_USER_ID,
|
||||
): RoomPowerLevels {
|
||||
return RoomPowerLevels(
|
||||
values = defaultRoomPowerLevelValues(),
|
||||
users = persistentMapOf(userId to role.powerLevel)
|
||||
)
|
||||
}
|
||||
|
||||
private fun roomPowerLevelsWithRoles(vararg pairs: Pair<UserId, RoomMember.Role>): RoomPowerLevels {
|
||||
return RoomPowerLevels(
|
||||
values = defaultRoomPowerLevelValues(),
|
||||
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createChangeRolesPresenter(
|
||||
role: RoomMember.Role = RoomMember.Role.Admin,
|
||||
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
): ChangeRolesPresenter {
|
||||
return ChangeRolesPresenter(
|
||||
role = role,
|
||||
room = room,
|
||||
dispatchers = dispatchers,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
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.clickOn
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangeRolesViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `passing a 'User' role throws an exception`() {
|
||||
val exception = runCatchingExceptions {
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.User,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertThat(exception).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key - with search active toggles the search`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBackKey()
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key - with search inactive exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBackKey()
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button - exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBack()
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save button - with changes, it saves them`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
hasPendingChanges = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save button - with no changes, does nothing`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
hasPendingChanges = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit confirmation dialog - submit exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
exitState = AsyncAction.ConfirmingNoParams,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit confirmation dialog - cancel removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
exitState = AsyncAction.ConfirmingNoParams,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save confirmation dialog - submit saves the changes`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.Admin,
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.ConfirmingNoParams,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save owners confirmation dialog - continue saves the changes`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.Owner(isCreator = false),
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.ConfirmingNoParams,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save confirmation dialog - cancel removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.Admin,
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.ConfirmingNoParams,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog - dismissing removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.Failure(IllegalStateException("boom")),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testing removing user from selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val userToDeselect = selectedUsers[1]
|
||||
assertThat(userToDeselect.displayName).isEqualTo("Bob")
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Unselect the user from the row list
|
||||
val contentDescription = rule.activity.getString(CommonStrings.action_remove)
|
||||
rule.onNodeWithContentDescription(
|
||||
label = contentDescription,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToDeselect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1000dp")
|
||||
fun `testing adding user to the selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.members.first().toMatrixUser()
|
||||
assertThat(userToSelect.displayName).isEqualTo("Carol")
|
||||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Select the user from the user list
|
||||
rule.onNodeWithText("Carol").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToSelect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testing removing user to the selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.moderators.first().toMatrixUser()
|
||||
assertThat(userToSelect.displayName).isEqualTo("Bob")
|
||||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Unselect the user from the user list
|
||||
rule.onAllNodesWithText(
|
||||
text = "Bob",
|
||||
useUnmergedTree = true,
|
||||
)[1].performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToSelect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
|
||||
state: ChangeRolesState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ChangeRolesView(
|
||||
state = state,
|
||||
navigateUp = onBackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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.changeroommemberroles.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
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.A_USER_ID_4
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_5
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_6
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_7
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
|
||||
class MembersByRoleTest {
|
||||
@Test
|
||||
fun `constructor - with single member list categorizes and sorts members`() {
|
||||
val members = listOf(
|
||||
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
|
||||
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
|
||||
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
|
||||
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
|
||||
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
|
||||
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
|
||||
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
|
||||
)
|
||||
val membersByRole = MembersByRole(members = members)
|
||||
assertThat(membersByRole.owners).containsExactly(
|
||||
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
|
||||
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
|
||||
)
|
||||
assertThat(membersByRole.admins).containsExactly(
|
||||
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
|
||||
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
|
||||
)
|
||||
assertThat(membersByRole.moderators).isEmpty()
|
||||
assertThat(membersByRole.members).containsExactly(
|
||||
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
|
||||
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
|
||||
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEmpty - only returns true with no members of any role`() {
|
||||
val emptyMembersByRole = MembersByRole(emptyList())
|
||||
assertThat(emptyMembersByRole.isEmpty()).isTrue()
|
||||
|
||||
val membersByRoleWithOwners = MembersByRole(
|
||||
owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
|
||||
admins = persistentListOf(),
|
||||
moderators = persistentListOf(),
|
||||
members = persistentListOf(),
|
||||
)
|
||||
assertThat(membersByRoleWithOwners.isEmpty()).isFalse()
|
||||
|
||||
val membersByRoleWithAdmins = MembersByRole(
|
||||
owners = persistentListOf(),
|
||||
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
|
||||
moderators = persistentListOf(),
|
||||
members = persistentListOf(),
|
||||
)
|
||||
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
|
||||
|
||||
val membersByRoleWithModerators = MembersByRole(
|
||||
owners = persistentListOf(),
|
||||
admins = persistentListOf(),
|
||||
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)),
|
||||
members = persistentListOf(),
|
||||
)
|
||||
assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
|
||||
|
||||
val membersByRoleWithMembers = MembersByRole(
|
||||
owners = persistentListOf(),
|
||||
admins = persistentListOf(),
|
||||
moderators = persistentListOf(),
|
||||
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)),
|
||||
)
|
||||
assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue