Room member moderation: kick, ban and unban (#2496)

* Room member moderation: kick, ban and unban

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-03-06 16:44:05 +01:00
committed by GitHub
parent 22715f4ea3
commit d8f9408cdb
113 changed files with 1410 additions and 83 deletions

1
changelog.d/2258.feature Normal file
View File

@@ -0,0 +1 @@
Room member moderation: kick, ban and unban users from a room.

View File

@@ -16,7 +16,10 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents {
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
}

View File

@@ -35,15 +35,16 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter,
presenterFactory: RoomMemberListPresenter.Factory,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), RoomMemberListNavigator {
interface Callback : Plugin {
fun openRoomMemberDetails(roomMemberId: UserId)
fun openInviteMembers()
}
private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(this)
init {
lifecycle.subscribe(
@@ -53,27 +54,35 @@ class RoomMemberListNode @AssistedInject constructor(
)
}
private fun openRoomMemberDetails(roomMemberId: UserId) {
override fun openRoomMemberDetails(roomMemberId: UserId) {
callbacks.forEach {
it.openRoomMemberDetails(roomMemberId)
}
}
private fun openInviteMembers() {
override fun openInviteMembers() {
callbacks.forEach {
it.openInviteMembers()
}
}
override fun exitRoomMemberList() {
navigateUp()
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomMemberListView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onMemberSelected = this::openRoomMemberDetails,
onInvitePressed = this::openInviteMembers,
navigator = this,
)
}
}
interface RoomMemberListNavigator {
fun exitRoomMemberList() {}
fun openRoomMemberDetails(roomMemberId: UserId) {}
fun openInviteMembers() {}
}

View File

@@ -23,32 +23,45 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomMemberListPresenter @Inject constructor(
class RoomMemberListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
private val featureFlagService: FeatureFlagService,
private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> {
@AssistedFactory
interface Factory {
fun create(navigator: RoomMemberListNavigator): RoomMemberListPresenter
}
@Composable
override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope()
var roomMembers by remember { mutableStateOf<AsyncData<RoomMembers>>(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember {
@@ -61,18 +74,14 @@ class RoomMemberListPresenter @Inject constructor(
value = room.canInvite().getOrElse { false }
}
val canDisplayBannedUsers by produceState(initialValue = false) {
val roomIsNotDmAndUserCanBan = !room.isDm && room.canBan().getOrElse { false }
if (roomIsNotDmAndUserCanBan) {
room.membersStateFlow
.onEach { members ->
val hasBannedUsers = members.roomMembers()?.any { it.membership == RoomMembershipState.BAN }.orFalse()
value = hasBannedUsers
val isRoomModerationEnabled by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
}
.collect()
val roomModerationState = if (isRoomModerationEnabled) {
roomMembersModerationPresenter.present()
} else {
value = false
}
remember { roomMembersModerationPresenter.dummyState() }
}
LaunchedEffect(membersState) {
@@ -116,19 +125,28 @@ class RoomMemberListPresenter @Inject constructor(
}
}
fun handleEvents(event: RoomMemberListEvents) {
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected -> coroutineScope.launch {
if (roomMembersModerationPresenter.canDisplayModerationActions()) {
roomModerationState.eventSink(RoomMembersModerationEvents.SelectRoomMember(event.roomMember))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
}
}
}
}
return RoomMemberListState(
roomMembers = roomMembers,
searchQuery = searchQuery,
searchResults = searchResults,
isSearchActive = isSearchActive,
canInvite = canInvite,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { event ->
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
}
},
moderationState = roomModerationState,
eventSink = { handleEvents(it) },
)
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -27,7 +28,7 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val canDisplayBannedUsers: Boolean,
val moderationState: RoomMembersModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
)

View File

@@ -17,6 +17,8 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@@ -57,30 +59,20 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
),
aRoomMemberListState().copy(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
banned = persistentListOf(),
)
),
canDisplayBannedUsers = true,
),
)
}
internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(),
canDisplayBannedUsers: Boolean = false,
moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
searchResults = searchResults,
isSearchActive = false,
canInvite = false,
canDisplayBannedUsers = canDisplayBannedUsers,
moderationState = moderationState,
eventSink = {}
)

View File

@@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -46,6 +47,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -76,14 +79,12 @@ private enum class SelectedSection {
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
onBackPressed: () -> Unit,
onInvitePressed: () -> Unit,
onMemberSelected: (UserId) -> Unit,
navigator: RoomMemberListNavigator,
modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0,
) {
fun onUserSelected(roomMember: RoomMember) {
onMemberSelected(roomMember.userId)
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
}
Scaffold(
@@ -92,13 +93,18 @@ fun RoomMemberListView(
if (!state.isSearchActive) {
RoomMemberListTopBar(
canInvite = state.canInvite,
onBackPressed = onBackPressed,
onInvitePressed = onInvitePressed,
onBackPressed = navigator::exitRoomMemberList,
onInvitePressed = navigator::openInviteMembers,
)
}
}
) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canDisplayBannedUsers && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -123,7 +129,7 @@ fun RoomMemberListView(
RoomMemberList(
roomMembers = state.roomMembers.data,
showMembersCount = true,
canDisplayBannedUsersControls = state.canDisplayBannedUsers,
canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
selectedSection = selectedSection,
onSelectedSectionChanged = { selectedSection = it },
onUserSelected = ::onUserSelected,
@@ -136,6 +142,11 @@ fun RoomMemberListView(
}
}
}
RoomMembersModerationView(
state = state.moderationState,
onDisplayMemberProfile = navigator::openRoomMemberDetails
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -328,9 +339,7 @@ private fun RoomMemberSearchBar(
internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
state = state,
onBackPressed = {},
onMemberSelected = {},
onInvitePressed = {},
navigator = object : RoomMemberListNavigator {},
)
}
@@ -351,10 +360,8 @@ internal fun RoomMemberBannedListPreview() = ElementPreview {
),
)
),
canDisplayBannedUsers = true,
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
),
onBackPressed = {},
onMemberSelected = {},
onInvitePressed = {},
navigator = object : RoomMemberListNavigator {},
)
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.finally
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : RoomMembersModerationPresenter {
private var selectedMember by mutableStateOf<RoomMember?>(null)
private suspend fun canBan() = room.canBan().getOrDefault(false)
private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean {
val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
val isDm = room.isDm && room.isEncrypted
return isRoomModerationEnabled && !isDm && (canBan() || canKick())
}
@Composable
override fun present(): RoomMembersModerationState {
val coroutineScope = rememberCoroutineScope()
var moderationActions by remember { mutableStateOf(persistentListOf<ModerationAction>()) }
val kickUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val banUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val canDisplayBannedUsers by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
}
fun handleEvent(event: RoomMembersModerationEvents) {
when (event) {
is RoomMembersModerationEvents.SelectRoomMember -> {
coroutineScope.launch {
selectedMember = event.roomMember
if (event.roomMember.membership == RoomMembershipState.BAN && canBan()) {
unbanUserAsyncAction.value = AsyncAction.Confirming
} else {
moderationActions = buildList {
add(ModerationAction.DisplayProfile(event.roomMember.userId))
val currentUserMemberPowerLevel = room.userRole(room.sessionId).getOrDefault(RoomMember.Role.USER).powerLevel
if (currentUserMemberPowerLevel > event.roomMember.powerLevel) {
if (canKick()) {
add(ModerationAction.KickUser(event.roomMember.userId))
}
if (canBan()) {
add(ModerationAction.BanUser(event.roomMember.userId))
}
}
}.toPersistentList()
}
}
}
is RoomMembersModerationEvents.KickUser -> {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
}
}
is RoomMembersModerationEvents.BanUser -> {
if (banUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.banUser(it.userId, banUserAsyncAction)
}
} else {
banUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.UnbanUser -> {
if (unbanUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
}
} else {
unbanUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.Reset -> {
selectedMember = null
moderationActions = persistentListOf()
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
}
}
}
return RoomMembersModerationState(
selectedRoomMember = selectedMember,
actions = moderationActions,
kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value,
unbanUserAsyncAction = unbanUserAsyncAction.value,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.kickUser(
userId: UserId,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
room.kickUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.banUser(
userId: UserId,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
room.banUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.unbanUser(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
room.unbanUser(userId).finally { selectedMember = null }
}
private fun <T> CoroutineScope.runActionAndWaitForMembershipChange(action: MutableState<AsyncAction<T>>, block: suspend () -> Result<T>) {
launch(dispatchers.io) {
action.runUpdatingState {
val result = block()
if (result.isSuccess) {
room.membersStateFlow.drop(1).take(1)
}
result
}
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data object UnbanUser : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
interface RoomMembersModerationPresenter : Presenter<RoomMembersModerationState> {
suspend fun canDisplayModerationActions(): Boolean
fun dummyState() = RoomMembersModerationState(
selectedRoomMember = null,
actions = persistentListOf(),
kickUserAsyncAction = AsyncAction.Uninitialized,
banUserAsyncAction = AsyncAction.Uninitialized,
unbanUserAsyncAction = AsyncAction.Uninitialized,
canDisplayBannedUsers = false,
eventSink = {}
)
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RoomMembersModerationState(
val selectedRoomMember: RoomMember?,
val actions: ImmutableList<ModerationAction>,
val kickUserAsyncAction: AsyncAction<Unit>,
val banUserAsyncAction: AsyncAction<Unit>,
val unbanUserAsyncAction: AsyncAction<Unit>,
val canDisplayBannedUsers: Boolean,
val eventSink: (RoomMembersModerationEvents) -> Unit,
)
sealed interface ModerationAction {
data class DisplayProfile(val userId: UserId) : ModerationAction
data class KickUser(val userId: UserId) : ModerationAction
data class BanUser(val userId: UserId) : ModerationAction
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
class RoomMembersModerationStatePreviewProvider : PreviewParameterProvider<RoomMembersModerationState> {
override val values: Sequence<RoomMembersModerationState>
get() = sequenceOf(
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
ModerationAction.BanUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Failure(Exception("Failed to kick user")),
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Success(Unit),
banUserAsyncAction = AsyncAction.Success(Unit),
unbanUserAsyncAction = AsyncAction.Success(Unit),
),
)
}
fun aRoomMembersModerationState(
selectedRoomMember: RoomMember? = null,
actions: List<ModerationAction> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
banUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canDisplayBannedUsers: Boolean = false,
eventSink: (RoomMembersModerationEvents) -> Unit = {},
) = RoomMembersModerationState(
selectedRoomMember = selectedRoomMember,
actions = actions.toPersistentList(),
kickUserAsyncAction = kickUserAsyncAction,
banUserAsyncAction = banUserAsyncAction,
unbanUserAsyncAction = unbanUserAsyncAction,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = eventSink,
)

View File

@@ -0,0 +1,318 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
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.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun RoomMembersModerationView(
state: RoomMembersModerationState,
onDisplayMemberProfile: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
if (state.actions.isNotEmpty()) {
RoomMemberActionsBottomSheet(
roomMember = state.selectedRoomMember,
actions = state.actions,
onActionSelected = { action ->
when (action) {
is ModerationAction.DisplayProfile -> {
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser)
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser)
}
}
},
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
when (val action = state.kickUserAsyncAction) {
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_removing_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to kick user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmitClicked = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_banning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to ban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.unbanUserAsyncAction) {
is AsyncAction.Confirming -> {
state.selectedRoomMember?.let {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_manage_member_unban_title),
content = stringResource(R.string.screen_room_member_list_manage_member_unban_message),
submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action),
onSubmitClicked = { state.eventSink(RoomMembersModerationEvents.UnbanUser) },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to unban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberActionsBottomSheet(
roomMember: RoomMember?,
actions: ImmutableList<ModerationAction>,
onActionSelected: (ModerationAction) -> Unit,
onDismiss: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
if (roomMember != null && actions.isNotEmpty()) {
val bottomSheetState = if (LocalInspectionMode.current) {
sheetStateForPreview()
} else {
rememberModalBottomSheetState(skipPartiallyExpanded = true)
}
ModalBottomSheet(
modifier = Modifier.systemBarsPadding(),
sheetState = bottomSheetState,
onDismissRequest = {
coroutineScope.launch {
bottomSheetState.hide()
onDismiss()
}
},
) {
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
Avatar(
avatarData = AvatarData(
id = roomMember.userId.value,
name = roomMember.displayName,
url = roomMember.avatarUrl,
size = AvatarSize.RoomListManageUser,
),
modifier = Modifier
.padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally)
)
roomMember.displayName?.let {
Text(
text = it,
style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
}
Text(
text = roomMember.userId.toString(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
for (action in actions) {
when (action) {
is ModerationAction.DisplayProfile -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_user_info)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = {
coroutineScope.launch {
onActionSelected(action)
bottomSheetState.hide()
}
}
)
}
is ModerationAction.KickUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
is ModerationAction.BanUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
style = ListItemStyle.Destructive,
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomMembersModerationViewPreview(@PreviewParameter(RoomMembersModerationStatePreviewProvider::class) state: RoomMembersModerationState) {
ElementPreview {
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp)) {
RoomMembersModerationView(
state = state,
onDisplayMemberProfile = {},
)
}
}
}

View File

@@ -22,13 +22,21 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListNavigator
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@@ -167,6 +175,51 @@ class RoomMemberListPresenterTests {
assertThat(loadedState.canInvite).isFalse()
}
}
@Test
fun `present - RoomMemberSelected by default opens the room member details through the navigator`() = runTest {
val navigator = FakeRoomMemberListNavigator()
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = false)
val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
assertThat(navigator.openRoomMemberDetailsCallCount).isEqualTo(1)
}
}
@Test
fun `present - RoomMemberSelected will open the moderation options if the current user can use them`() = runTest {
val navigator = FakeRoomMemberListNavigator()
var selectRoomMemberCallCounts = 0
val capturingState = aRoomMembersModerationState(eventSink = { event ->
if (event is RoomMembersModerationEvents.SelectRoomMember) {
selectRoomMemberCallCounts++
}
})
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = true).apply {
givenState(capturingState)
}
val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
assertThat(selectRoomMemberCallCounts).isEqualTo(1)
}
}
}
private class FakeRoomMemberListNavigator : RoomMemberListNavigator {
var openRoomMemberDetailsCallCount = 0
private set
override fun openRoomMemberDetails(userId: UserId) {
openRoomMemberDetailsCallCount++
}
}
@ExperimentalCoroutinesApi
@@ -182,4 +235,14 @@ private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers)
featureFlagService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
) = RoomMemberListPresenter(
room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
featureFlagService = featureFlagService,
roomMembersModerationPresenter = moderationPresenter,
navigator = navigator
)

View File

@@ -0,0 +1,313 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members.moderation
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.moderation.DefaultRoomMembersModerationPresenter
import io.element.android.features.roomdetails.impl.members.moderation.ModerationAction
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
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.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultRoomMembersModerationPresenterTests {
@Test
fun `canDisplayModerationActions - when feature flag is disabled returns false`() = runTest {
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to false))
val presenter = createDefaultRoomMembersModerationPresenter(featureFlagService = featureFlagService)
assertThat(presenter.canDisplayModerationActions()).isFalse()
}
@Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
givenRoomInfo(aRoomInfo(isDirect = true, isPublic = false, activeMembersCount = 2))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isFalse()
}
@Test
fun `canDisplayModerationActions - when user can kick other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
givenCanKickResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `canDisplayModerationActions - when user can ban other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
givenCanBanResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `present - SelectRoomMember when the current user has permissions displays member actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
ModerationAction.KickUser(selectedMember.userId),
ModerationAction.BanUser(selectedMember.userId)
)
}
}
}
@Test
fun `present - SelectRoomMember displays only view profile if selected member has same power level as the current user`() = runTest {
val room = FakeMatrixRoom(sessionId = A_USER_ID).apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aRoomMember(A_USER_ID_2, powerLevel = 100L)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
)
}
}
}
@Test
fun `present - SelectRoomMember displays an unban confirmation dialog when the member is banned`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(selectedRoomMember).isNotNull()
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Confirming)
}
}
}
@Test
fun `present - Kick removes the user`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
skipItems(1)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().kickUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(kickUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.Confirming)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
skipItems(1)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().banUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(banUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - UnbanUser requires confirmation and then unbans the user`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(selectedMember)))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Displays confirmation dialog
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
// Confirms unban
awaitItem().eventSink(RoomMembersModerationEvents.UnbanUser)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - Reset removes the selected user and actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Displays confirmation dialog
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
// Reset state
awaitItem().eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().selectedRoomMember).isNull()
assertThat(awaitItem().actions).isEmpty()
}
}
@Test
fun `present - Reset resets any async actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenKickUserResult(Result.failure(Throwable("Eek")))
givenBanUserResult(Result.failure(Throwable("Eek")))
givenUnbanUserResult(Result.failure(Throwable("Eek")))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
skipItems(2)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
skipItems(2)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Unban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor().copy(membership = RoomMembershipState.BAN)))
val confirmingState = awaitItem()
assertThat(confirmingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Confirming::class.java)
confirmingState.eventSink(RoomMembersModerationEvents.UnbanUser)
skipItems(1)
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun TestScope.createDefaultRoomMembersModerationPresenter(
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): DefaultRoomMembersModerationPresenter {
return DefaultRoomMembersModerationPresenter(
room = matrixRoom,
featureFlagService = featureFlagService,
dispatchers = dispatchers,
)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
class FakeRoomMembersModerationPresenter(
private val canDisplayModerationActions: Boolean = true,
) : RoomMembersModerationPresenter {
private var state by mutableStateOf(dummyState())
override suspend fun canDisplayModerationActions(): Boolean {
return canDisplayModerationActions
}
@Composable
override fun present(): RoomMembersModerationState {
return state
}
fun givenState(state: RoomMembersModerationState) {
this.state = state
}
}

View File

@@ -20,7 +20,7 @@ For now, you can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Youre not in any room yet"</string>
<string name="screen_roomlist_filter_unreads">"Unreads"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Congrats!
You dont have any unread message!"</string>
You dont have any unread messages!"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>

View File

@@ -79,9 +79,9 @@ sealed interface AsyncAction<out T> {
fun isUninitialized(): Boolean = this == Uninitialized
fun isConfirming(): Boolean = this is Confirming
fun isConfirming(): Boolean = this == Confirming
fun isLoading(): Boolean = this is Loading
fun isLoading(): Boolean = this == Loading
fun isFailure(): Boolean = this is Failure

View File

@@ -47,3 +47,9 @@ inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result
onFailure = { Result.failure(it) }
)
}
inline fun <T> Result<T>.finally(block: (exception: Throwable?) -> Unit): Result<T> {
onSuccess { block(null) }
onFailure(block)
return this
}

View File

@@ -47,6 +47,7 @@ enum class AvatarSize(val dp: Dp) {
InviteSender(16.dp),
EditRoomDetails(70.dp),
RoomListManageUser(70.dp),
NotificationsOptIn(32.dp),

View File

@@ -193,7 +193,7 @@ class AsyncIndicatorTests {
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
var firstItem: Any? = null
var firstItem: Any?
skipItems(1)
state.enqueue(composable = {})
state.enqueue(composable = {})

View File

@@ -140,6 +140,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserInvite(userId: UserId): Result<Boolean>
suspend fun canUserKick(userId: UserId): Result<Boolean>
suspend fun canUserBan(userId: UserId): Result<Boolean>
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
@@ -177,6 +179,12 @@ interface MatrixRoom : Closeable {
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
suspend fun kickUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun banUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun unbanUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
/**

View File

@@ -25,6 +25,11 @@ import io.element.android.libraries.matrix.api.room.StateEventType
*/
suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
/**
* Shortcut for calling [MatrixRoom.canUserKick] with our own user.
*/
suspend fun MatrixRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
/**
* Shortcut for calling [MatrixRoom.canBanUser] with our own user.
*/

View File

@@ -39,6 +39,7 @@ interface MatrixTimeline : AutoCloseable {
val paginationState: StateFlow<PaginationState>
val timelineItems: Flow<List<MatrixTimelineItem>>
val membershipChangeEventReceived: Flow<Unit>
suspend fun paginateBackwards(requestSize: Int): Result<Unit>
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>

View File

@@ -70,6 +70,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -159,6 +161,12 @@ class RustMatrixRoom(
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
init {
timeline.membershipChangeEventReceived
.onEach { roomMemberListFetcher.fetchRoomMembers() }
.launchIn(roomCoroutineScope)
}
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
@@ -340,6 +348,12 @@ class RustMatrixRoom(
}
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserKick(userId.value)
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserBan(userId.value)
@@ -469,6 +483,24 @@ class RustMatrixRoom(
}
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.kickUser(userId.value, reason)
}
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.banUser(userId.value, reason)
}
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.unbanUser(userId.value, reason)
}
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.setIsFavourite(isFavorite, null)

View File

@@ -65,7 +65,7 @@ internal class RoomMemberListFetcher(
if (_membersFlow.value !is MatrixRoomMembersState.Ready) {
fetchCachedRoomMembers()
} else {
Timber.i("No need to load cached members found for room $roomId")
Timber.i("Cached members not found for $roomId")
}
}

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
@@ -55,6 +56,8 @@ class AsyncMatrixTimeline(
}
private val closeSignal = CompletableDeferred<Unit>()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
init {
coroutineScope.launch {
val delegateTimeline = timeline.await()
@@ -64,6 +67,9 @@ class AsyncMatrixTimeline(
delegateTimeline.paginationState
.onEach { _paginationState.value = it }
.launchIn(this)
delegateTimeline.membershipChangeEventReceived
.onEach { membershipChangeEventReceived.emit(it) }
.launchIn(this)
launch {
withContext(NonCancellable) {

View File

@@ -17,6 +17,9 @@
package io.element.android.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -31,6 +34,9 @@ internal class MatrixTimelineDiffProcessor(
) {
private val mutex = Mutex()
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
suspend fun postItems(items: List<TimelineItem>) {
updateTimelineItems {
Timber.v("Update timeline items from postItems (with ${items.size} items) on ${Thread.currentThread()}")
@@ -63,6 +69,11 @@ internal class MatrixTimelineDiffProcessor(
}
TimelineChange.PUSH_BACK -> {
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
if (item is MatrixTimelineItem.Event && item.event.content is RoomMembershipContent) {
// TODO - This is a temporary solution to notify the room screen about membership changes
// Ideally, this should be implemented by the Rust SDK
_membershipChangeEventReceived.tryEmit(Unit)
}
add(item)
}
TimelineChange.PUSH_FRONT -> {

View File

@@ -114,6 +114,8 @@ class RustMatrixTimeline(
)
}
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
init {
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")

View File

@@ -95,6 +95,7 @@ class FakeMatrixRoom(
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private var canKickResult = Result.success(false)
private var canBanResult = Result.success(false)
private var canRedactOwnResult = Result.success(canRedactOwn)
private var canRedactOtherResult = Result.success(canRedactOther)
@@ -111,6 +112,9 @@ class FakeMatrixRoom(
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit)
private var kickUserResult = Result.success(Unit)
private var banUserResult = Result.success(Unit)
private var unBanUserResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit)
private var editPollResult = Result.success(Unit)
@@ -299,6 +303,10 @@ class FakeMatrixRoom(
return canBanResult
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return canKickResult
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> {
return canInviteResult
}
@@ -398,6 +406,18 @@ class FakeMatrixRoom(
return reportContentResult
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> {
return kickUserResult
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> {
return banUserResult
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> {
return unBanUserResult
}
val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
@@ -522,6 +542,10 @@ class FakeMatrixRoom(
joinRoomResult = result
}
fun givenCanKickResult(result: Result<Boolean>) {
canKickResult = result
}
fun givenCanBanResult(result: Result<Boolean>) {
canBanResult = result
}
@@ -598,6 +622,18 @@ class FakeMatrixRoom(
reportContentResult = result
}
fun givenKickUserResult(result: Result<Unit>) {
kickUserResult = result
}
fun givenBanUserResult(result: Result<Unit>) {
banUserResult = result
}
fun givenUnbanUserResult(result: Result<Unit>) {
unBanUserResult = result
}
fun givenSendLocationResult(result: Result<Unit>) {
sendLocationResult = result
}

View File

@@ -24,6 +24,7 @@ import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
@@ -59,6 +60,8 @@ class FakeMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards()
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>()
private suspend fun paginateBackwards(): Result<Unit> {
updatePaginationState {
copy(isBackPaginating = true)
@@ -73,6 +76,10 @@ class FakeMatrixTimeline(
return Result.success(Unit)
}
fun givenMembershipChangeEventReceived() {
membershipChangeEventReceived.tryEmit(Unit)
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask {
Result.success(Unit)
}

View File

@@ -114,6 +114,7 @@
<string name="common_audio">"Audio"</string>
<string name="common_blocked_users">"Blocked users"</string>
<string name="common_bubbles">"Bubbles"</string>
<string name="common_call_invite">"Call in progress (unsupported)"</string>
<string name="common_chat_backup">"Chat backup"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string>

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