Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve room moderation #3671

Merged
merged 3 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.features.roomdetails.impl.members.moderation

import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember

data class ConfirmingRoomMemberAction(
val roomMember: RoomMember,
) : AsyncAction.Confirming
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@

package io.element.android.features.roomdetails.impl.members.moderation

import io.element.android.libraries.matrix.api.core.UserId
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 class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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
Expand All @@ -21,16 +21,15 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
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.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.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import io.element.android.libraries.matrix.ui.room.canBanAsState
import io.element.android.libraries.matrix.ui.room.canKickAsState
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
Expand All @@ -45,86 +44,79 @@ class RoomMembersModerationPresenter @Inject constructor(
) : Presenter<RoomMembersModerationState> {
private var selectedMember by mutableStateOf<RoomMember?>(null)

private suspend fun canBan() = room.canBan().getOrDefault(false)
private suspend fun canKick() = room.canKick().getOrDefault(false)

@Composable
override fun present(): RoomMembersModerationState {
val coroutineScope = rememberCoroutineScope()
var moderationActions by remember { mutableStateOf(persistentListOf<ModerationAction>()) }

val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canDisplayModerationActions by produceState(
initialValue = false,
key1 = syncUpdateFlow.value
) {
value = !room.isDm && (canBan() || canKick())
val canBan by room.canBanAsState(syncUpdateFlow.value)
val canKick by room.canKickAsState(syncUpdateFlow.value)
val isDm by room.isDmAsState(syncUpdateFlow.value)
val currentUserMemberPowerLevel by room.userPowerLevelAsState(syncUpdateFlow.value)

val canDisplayModerationActions by remember {
derivedStateOf { !isDm && (canBan || canKick) }
}
val canDisplayBannedUsers by remember {
derivedStateOf { !isDm && canBan }
}
val moderationActions by remember {
derivedStateOf {
buildList {
selectedMember?.let { roomMember ->
add(ModerationAction.DisplayProfile(roomMember.userId))
if (currentUserMemberPowerLevel > roomMember.powerLevel) {
if (canKick) {
add(ModerationAction.KickUser(roomMember.userId))
}
if (canBan) {
add(ModerationAction.BanUser(roomMember.userId))
}
}
}
}.toPersistentList()
}
}

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 = !room.isDm && canBan()
}

fun handleEvent(event: RoomMembersModerationEvents) {
when (event) {
is RoomMembersModerationEvents.SelectRoomMember -> {
coroutineScope.launch {
if (event.roomMember.membership == RoomMembershipState.BAN && canBan) {
// In this case the view will render a dialog to confirm the unbanning of the user
unbanUserAsyncAction.value = ConfirmingRoomMemberAction(event.roomMember)
} else {
// In this case the view will render a bottom sheet.
selectedMember = event.roomMember
if (event.roomMember.membership == RoomMembershipState.BAN && canBan()) {
unbanUserAsyncAction.value = AsyncAction.ConfirmingNoParams
} 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)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
if (banUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.banUser(it.userId, banUserAsyncAction)
}
selectedMember = null
} else {
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
}
is RoomMembersModerationEvents.UnbanUser -> {
if (unbanUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
}
} else {
unbanUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
// We are already confirming when we are reaching this point
coroutineScope.unbanUser(event.userId, unbanUserAsyncAction)
}
is RoomMembersModerationEvents.Reset -> {
selectedMember = null
moderationActions = persistentListOf()
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
Expand All @@ -149,23 +141,23 @@ class RoomMembersModerationPresenter @Inject constructor(
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
room.kickUser(userId).finally { selectedMember = null }
room.kickUser(userId)
}

private fun CoroutineScope.banUser(
userId: UserId,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
room.banUser(userId).finally { selectedMember = null }
room.banUser(userId)
}

private fun CoroutineScope.unbanUser(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember))
room.unbanUser(userId).finally { selectedMember = null }
room.unbanUser(userId)
}

private fun <T> CoroutineScope.runActionAndWaitForMembershipChange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.ConfirmingNoParams,
unbanUserAsyncAction = ConfirmingRoomMemberAction(anAlice()),
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Success(Unit),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
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),
onSubmitClick = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } },
onSubmitClick = { state.eventSink(RoomMembersModerationEvents.BanUser) },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
)
}
Expand Down Expand Up @@ -147,24 +147,22 @@

when (val action = state.unbanUserAsyncAction) {
is AsyncAction.Confirming -> {
state.selectedRoomMember?.let {
if (action is ConfirmingRoomMemberAction) {
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),
onSubmitClick = { state.eventSink(RoomMembersModerationEvents.UnbanUser) },
onSubmitClick = {
val userDisplayName = action.roomMember.getBestName()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))

Check warning on line 158 in features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt

View check run for this annotation

Codecov / codecov/patch

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt#L158

Added line #L158 was not covered by tests
}
state.eventSink(RoomMembersModerationEvents.UnbanUser(action.roomMember.userId))
},
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) {
Expand All @@ -178,7 +176,8 @@
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
is AsyncAction.Loading,
AsyncAction.Uninitialized -> Unit
}
}
}
Expand Down
Loading
Loading