change (member moderation) : extract in a separate module

This commit is contained in:
ganfra 2025-05-06 22:39:18 +02:00
parent 7ba0d75b5c
commit 5272587897
80 changed files with 1062 additions and 633 deletions

View file

@ -1,23 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@Module
@ContributesTo(RoomScope::class)
interface RoomDetailsModule {
@Binds
fun bindRoomMembersModerationPresenter(presenter: RoomMembersModerationPresenter): Presenter<RoomMembersModerationState>
}

View file

@ -18,6 +18,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.services.analytics.api.AnalyticsService
@ -28,6 +31,7 @@ class RoomMemberListNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
presenterFactory: RoomMemberListPresenter.Factory,
private val analyticsService: AnalyticsService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), RoomMemberListNavigator {
interface Callback : Plugin {
fun openRoomMemberDetails(roomMemberId: UserId)
@ -69,6 +73,16 @@ class RoomMemberListNode @AssistedInject constructor(
modifier = modifier,
navigator = this,
)
roomMemberModerationRenderer.Render(
state = state.moderationState,
onSelectAction = { action ->
when (action) {
is ModerationAction.DisplayProfile -> openRoomMemberDetails(action.member.userId)
else -> state.moderationState.eventSink(RoomMemberModerationEvents.ProcessAction(action))
}
},
modifier = Modifier,
)
}
}

View file

@ -19,8 +19,9 @@ 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.RoomMembersModerationState
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -34,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
@ -48,7 +50,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
private val room: JoinedRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersModerationPresenter: Presenter<RoomMembersModerationState>,
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> {
@ -69,7 +71,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
val membersState by room.membersStateFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
val isDm = room.isDmAsState()
val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) {
@ -163,8 +165,10 @@ class RoomMemberListPresenter @AssistedInject constructor(
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected ->
if (roomModerationState.canDisplayModerationActions) {
roomModerationState.eventSink(RoomMembersModerationEvents.SelectRoomMember(event.roomMember))
if (event.roomMember.membership == RoomMembershipState.BAN) {
roomModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(ModerationAction.UnbanUser(event.roomMember)))
} else if (!isDm.value && (roomModerationState.canBan || roomModerationState.canKick)) {
roomModerationState.eventSink(RoomMemberModerationEvents.RenderActions(event.roomMember))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
}

View file

@ -7,7 +7,7 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@ -20,7 +20,7 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val moderationState: RoomMembersModerationState,
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
)

View file

@ -8,8 +8,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.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
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
@ -87,7 +87,7 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
),
)
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
moderationState = aRoomMemberModerationState(),
),
aRoomMemberListState(
roomMembers = AsyncData.Loading(
@ -101,7 +101,7 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
),
)
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
moderationState = aRoomMemberModerationState(),
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
@ -111,7 +111,7 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
banned = persistentListOf(),
)
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
moderationState = aRoomMemberModerationState(),
)
)
}
@ -119,7 +119,7 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
@ -130,6 +130,17 @@ internal fun aRoomMemberListState(
eventSink = {}
)
fun aRoomMemberModerationState(
canBan: Boolean = false,
canKick: Boolean = false,
): RoomMemberModerationState {
return object : RoomMemberModerationState {
override val canKick: Boolean = canKick
override val canBan: Boolean = canBan
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}
}
fun aRoomMember(
userId: UserId = UserId("@alice:server.org"),
displayName: String? = null,

View file

@ -45,7 +45,7 @@ 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.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
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
@ -99,7 +99,7 @@ fun RoomMemberListView(
}
) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canDisplayBannedUsers && selectedSection == SelectedSection.BANNED) {
if (!state.moderationState.canBan && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
@ -127,7 +127,7 @@ fun RoomMemberListView(
RoomMemberList(
roomMembers = state.roomMembers,
showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
canDisplayBannedUsersControls = state.moderationState.canBan,
selectedSection = selectedSection,
onSelectedSectionChange = { selectedSection = it },
onSelectUser = ::onSelectUser,
@ -135,11 +135,6 @@ fun RoomMemberListView(
}
}
}
RoomMembersModerationView(
state = state.moderationState,
onDisplayMemberProfile = navigator::openRoomMemberDetails
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)

View file

@ -1,15 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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

View file

@ -1,21 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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 class DoKickUser(val reason: String) : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data class DoBanUser(val reason: String) : RoomMembersModerationEvents
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View file

@ -1,187 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.RoomMembershipState
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.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import javax.inject.Inject
class RoomMembersModerationPresenter @Inject constructor(
private val room: JoinedRoom,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : Presenter<RoomMembersModerationState> {
private var selectedMember by mutableStateOf<RoomMember?>(null)
@Composable
override fun present(): RoomMembersModerationState {
val coroutineScope = rememberCoroutineScope()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canBan by room.canBanAsState(syncUpdateFlow.value)
val canKick by room.canKickAsState(syncUpdateFlow.value)
val isDm by room.isDmAsState()
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>) }
fun handleEvent(event: RoomMembersModerationEvents) {
when (event) {
is RoomMembersModerationEvents.SelectRoomMember -> {
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
}
}
is RoomMembersModerationEvents.KickUser -> {
kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is RoomMembersModerationEvents.DoKickUser -> {
selectedMember?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is RoomMembersModerationEvents.DoBanUser -> {
selectedMember?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.UnbanUser -> {
// We are already confirming when we are reaching this point
coroutineScope.unbanUser(event.userId, unbanUserAsyncAction)
}
is RoomMembersModerationEvents.Reset -> {
selectedMember = null
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
}
}
}
return RoomMembersModerationState(
canDisplayModerationActions = canDisplayModerationActions,
selectedRoomMember = selectedMember,
actions = moderationActions,
kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value,
unbanUserAsyncAction = unbanUserAsyncAction.value,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.kickUser(
userId: UserId,
reason: String,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
room.kickUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
}
private fun CoroutineScope.banUser(
userId: UserId,
reason: String,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
room.banUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
}
private fun CoroutineScope.unbanUser(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember))
room.unbanUser(userId)
}
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

@ -1,30 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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 canDisplayModerationActions: Boolean,
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

@ -1,95 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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.toPersistentList
class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersModerationState> {
override val values: Sequence<RoomMembersModerationState>
get() = sequenceOf(
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
ModerationAction.BanUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
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(),
unbanUserAsyncAction = ConfirmingRoomMemberAction(anAlice()),
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Success(Unit),
banUserAsyncAction = AsyncAction.Success(Unit),
unbanUserAsyncAction = AsyncAction.Success(Unit),
),
)
}
fun aRoomMembersModerationState(
canDisplayModerationActions: Boolean = false,
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(
canDisplayModerationActions = canDisplayModerationActions,
selectedRoomMember = selectedRoomMember,
actions = actions.toPersistentList(),
kickUserAsyncAction = kickUserAsyncAction,
banUserAsyncAction = banUserAsyncAction,
unbanUserAsyncAction = unbanUserAsyncAction,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = eventSink,
)

View file

@ -1,319 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.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.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.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.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.matrix.ui.model.getAvatarData
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.selectedRoomMember != null && state.actions.isNotEmpty()) {
RoomMemberActionsBottomSheet(
roomMember = state.selectedRoomMember,
actions = state.actions,
onSelectAction = { 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.Confirming -> {
TextFieldDialog(
title = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_kick_member_confirmation_title),
submitText = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action),
onSubmit = { reason ->
state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
content = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_kick_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_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 -> {
TextFieldDialog(
title = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_ban_member_confirmation_title),
submitText = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action),
onSubmit = { reason ->
state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
content = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_ban_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_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 -> {
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 = {
val userDisplayName = action.roomMember.getBestName()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))
}
state.eventSink(RoomMembersModerationEvents.UnbanUser(action.roomMember.userId))
},
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
}
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() }
}
is AsyncAction.Loading,
AsyncAction.Uninitialized -> Unit
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberActionsBottomSheet(
roomMember: RoomMember,
actions: ImmutableList<ModerationAction>,
onSelectAction: (ModerationAction) -> Unit,
onDismiss: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
modifier = Modifier.systemBarsPadding(),
sheetState = bottomSheetState,
onDismissRequest = {
coroutineScope.launch {
bottomSheetState.hide()
onDismiss()
}
},
) {
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
Avatar(
avatarData = roomMember.getAvatarData(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(CommonStrings.screen_bottom_sheet_manage_room_member_member_user_info)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = {
coroutineScope.launch {
onSelectAction(action)
bottomSheetState.hide()
}
}
)
}
is ModerationAction.KickUser -> {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.screen_bottom_sheet_manage_room_member_remove)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onSelectAction(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()
onSelectAction(action)
}
}
)
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomMembersModerationViewPreview(@PreviewParameter(RoomMembersModerationStateProvider::class) state: RoomMembersModerationState) {
ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
) {
RoomMembersModerationView(
state = state,
onDisplayMemberProfile = {},
)
}
}
}