Add kick (remove) confirmation and reason (#4507)
* Add confirmation dialog when kicking someone, ith ability to provide a reason. Also add the reason for banning people. * Fix padding issue in dialogs. * Improve TextField in dialog. * Update screenshots * Fix tests * Format and import * Add missing UI tests. * Use `needsConfirmation` as it's already used in the code base. --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members.moderation
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
data class ConfirmingWithReason(
|
||||
val reason: String,
|
||||
) : AsyncAction.Confirming
|
||||
@@ -12,8 +12,8 @@ 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 class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
|
||||
data class BanUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
|
||||
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
|
||||
data object Reset : RoomMembersModerationEvents
|
||||
}
|
||||
|
||||
@@ -96,19 +96,23 @@ class RoomMembersModerationPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
is RoomMembersModerationEvents.KickUser -> {
|
||||
selectedMember?.let {
|
||||
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
|
||||
}
|
||||
selectedMember = null
|
||||
}
|
||||
is RoomMembersModerationEvents.BanUser -> {
|
||||
if (banUserAsyncAction.value.isConfirming()) {
|
||||
if (event.needsConfirmation) {
|
||||
kickUserAsyncAction.value = ConfirmingWithReason(event.reason)
|
||||
} else {
|
||||
selectedMember?.let {
|
||||
coroutineScope.banUser(it.userId, banUserAsyncAction)
|
||||
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
|
||||
}
|
||||
selectedMember = null
|
||||
}
|
||||
}
|
||||
is RoomMembersModerationEvents.BanUser -> {
|
||||
if (event.needsConfirmation) {
|
||||
banUserAsyncAction.value = ConfirmingWithReason(event.reason)
|
||||
} else {
|
||||
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
|
||||
selectedMember?.let {
|
||||
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
|
||||
}
|
||||
selectedMember = null
|
||||
}
|
||||
}
|
||||
is RoomMembersModerationEvents.UnbanUser -> {
|
||||
@@ -138,18 +142,26 @@ class RoomMembersModerationPresenter @Inject constructor(
|
||||
|
||||
private fun CoroutineScope.kickUser(
|
||||
userId: UserId,
|
||||
reason: String,
|
||||
kickUserAction: MutableState<AsyncAction<Unit>>,
|
||||
) = runActionAndWaitForMembershipChange(kickUserAction) {
|
||||
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
|
||||
room.kickUser(userId)
|
||||
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)
|
||||
room.banUser(
|
||||
userId = userId,
|
||||
reason = reason.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.unbanUser(
|
||||
|
||||
@@ -37,10 +37,26 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
|
||||
ModerationAction.BanUser(userId = anAlice().userId),
|
||||
),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
kickUserAsyncAction = ConfirmingWithReason(""),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
kickUserAsyncAction = ConfirmingWithReason("A reason"),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
kickUserAsyncAction = AsyncAction.Loading,
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
banUserAsyncAction = ConfirmingWithReason(""),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
banUserAsyncAction = ConfirmingWithReason("A reason"),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
banUserAsyncAction = AsyncAction.Loading,
|
||||
@@ -54,10 +70,6 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
|
||||
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
|
||||
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
||||
),
|
||||
aRoomMembersModerationState(
|
||||
selectedRoomMember = anAlice(),
|
||||
unbanUserAsyncAction = ConfirmingRoomMemberAction(anAlice()),
|
||||
|
||||
@@ -38,7 +38,9 @@ import io.element.android.libraries.designsystem.components.async.rememberAsyncI
|
||||
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.ListDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
|
||||
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
|
||||
@@ -72,10 +74,10 @@ fun RoomMembersModerationView(
|
||||
onDisplayMemberProfile(action.userId)
|
||||
}
|
||||
is ModerationAction.KickUser -> {
|
||||
state.eventSink(RoomMembersModerationEvents.KickUser)
|
||||
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
|
||||
}
|
||||
is ModerationAction.BanUser -> {
|
||||
state.eventSink(RoomMembersModerationEvents.BanUser)
|
||||
state.eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -87,6 +89,47 @@ fun RoomMembersModerationView(
|
||||
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
|
||||
|
||||
when (val action = state.kickUserAsyncAction) {
|
||||
is AsyncAction.Confirming -> {
|
||||
if (action is ConfirmingWithReason) {
|
||||
ListDialog(
|
||||
title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title),
|
||||
submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action),
|
||||
onSubmit = {
|
||||
state.eventSink(
|
||||
RoomMembersModerationEvents.KickUser(
|
||||
reason = action.reason,
|
||||
needsConfirmation = false,
|
||||
)
|
||||
)
|
||||
},
|
||||
applyPaddingToContents = true,
|
||||
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
|
||||
style = ElementTheme.materialTypography.bodyMedium,
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextFieldListItem(
|
||||
placeholder = stringResource(id = CommonStrings.common_reason),
|
||||
label = stringResource(id = CommonStrings.common_reason),
|
||||
withBorder = true,
|
||||
text = action.reason,
|
||||
onTextChange = { newText ->
|
||||
state.eventSink(
|
||||
RoomMembersModerationEvents.KickUser(
|
||||
reason = newText,
|
||||
needsConfirmation = true,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is AsyncAction.Loading -> {
|
||||
LaunchedEffect(action) {
|
||||
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
|
||||
@@ -113,13 +156,45 @@ fun RoomMembersModerationView(
|
||||
|
||||
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),
|
||||
onSubmitClick = { state.eventSink(RoomMembersModerationEvents.BanUser) },
|
||||
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
|
||||
)
|
||||
if (action is ConfirmingWithReason) {
|
||||
ListDialog(
|
||||
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
|
||||
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
|
||||
onSubmit = {
|
||||
state.eventSink(
|
||||
RoomMembersModerationEvents.BanUser(
|
||||
reason = action.reason,
|
||||
needsConfirmation = false,
|
||||
)
|
||||
)
|
||||
},
|
||||
applyPaddingToContents = true,
|
||||
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
|
||||
style = ElementTheme.materialTypography.bodyMedium,
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextFieldListItem(
|
||||
placeholder = stringResource(id = CommonStrings.common_reason),
|
||||
label = stringResource(id = CommonStrings.common_reason),
|
||||
withBorder = true,
|
||||
text = action.reason,
|
||||
onTextChange = { newText ->
|
||||
state.eventSink(
|
||||
RoomMembersModerationEvents.BanUser(
|
||||
reason = newText,
|
||||
needsConfirmation = true,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is AsyncAction.Loading -> {
|
||||
LaunchedEffect(action) {
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
<item quantity="one">"%1$d person"</item>
|
||||
<item quantity="other">"%1$d people"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_action">"Remove"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_description">"They will be able to join this room again if invited."</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_title">"Are you sure you want to remove this member?"</string>
|
||||
<string name="screen_room_member_list_manage_member_ban">"Remove and ban member"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove">"Remove from room"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Remove and ban member"</string>
|
||||
|
||||
@@ -17,14 +17,18 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aVictor
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
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.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_REASON
|
||||
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.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -153,13 +157,14 @@ class RoomMembersModerationPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Kick removes the user`() = runTest {
|
||||
fun `present - Kick requires confirmation and then kicks the user`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val kickUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||
val room = aMatrixRoom(
|
||||
canKickResult = { Result.success(true) },
|
||||
canBanResult = { Result.success(true) },
|
||||
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
||||
kickUserResult = { _, _ -> Result.success(Unit) },
|
||||
kickUserResult = kickUserResult,
|
||||
)
|
||||
val selectedMember = aVictor()
|
||||
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||
@@ -168,7 +173,15 @@ class RoomMembersModerationPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason(""))
|
||||
// Change the reason
|
||||
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = true))
|
||||
val confirmingWithReasonState = awaitItem()
|
||||
assertThat(confirmingWithReasonState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
|
||||
// Confirm
|
||||
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.actions).isEmpty()
|
||||
@@ -178,17 +191,22 @@ class RoomMembersModerationPresenterTest {
|
||||
assertThat(selectedRoomMember).isNull()
|
||||
}
|
||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
|
||||
kickUserResult.assertions().isCalledOnce().with(
|
||||
value(selectedMember.userId),
|
||||
value(A_REASON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val banUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||
val room = aMatrixRoom(
|
||||
canKickResult = { Result.success(true) },
|
||||
canBanResult = { Result.success(true) },
|
||||
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
||||
banUserResult = { _, _ -> Result.success(Unit) },
|
||||
banUserResult = banUserResult,
|
||||
)
|
||||
val selectedMember = aVictor()
|
||||
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||
@@ -197,12 +215,15 @@ class RoomMembersModerationPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
|
||||
assertThat(confirmingState.banUserAsyncAction).isEqualTo(ConfirmingWithReason(""))
|
||||
// Change the reason
|
||||
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = true))
|
||||
val confirmingWithReasonState = awaitItem()
|
||||
assertThat(confirmingWithReasonState.banUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
|
||||
// Confirm
|
||||
confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
|
||||
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
|
||||
skipItems(1)
|
||||
val loadingItem = awaitItem()
|
||||
assertThat(loadingItem.actions).isEmpty()
|
||||
@@ -213,6 +234,10 @@ class RoomMembersModerationPresenterTest {
|
||||
assertThat(selectedRoomMember).isNull()
|
||||
}
|
||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
|
||||
banUserResult.assertions().isCalledOnce().with(
|
||||
value(selectedMember.userId),
|
||||
value(A_REASON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +314,7 @@ class RoomMembersModerationPresenterTest {
|
||||
val initialItem = awaitItem()
|
||||
// Kick user and fail
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
@@ -299,8 +324,7 @@ class RoomMembersModerationPresenterTest {
|
||||
|
||||
// Ban user and fail
|
||||
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
|
||||
@@ -10,11 +10,13 @@ package io.element.android.features.roomdetails.impl.members.moderation
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.members.anAlice
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.A_REASON
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
@@ -92,7 +94,57 @@ class RoomMembersModerationViewTest {
|
||||
rule.clickOn(R.string.screen_room_member_list_manage_member_remove)
|
||||
// Give time for the bottom sheet to animate
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling 'Remove member' confirmation emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
state = state,
|
||||
)
|
||||
// Note: the string key semantics is not perfect here :/
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming 'Remove member' reason edition emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
state = state,
|
||||
)
|
||||
rule.onNodeWithText(A_REASON).performTextInput("z")
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "z$A_REASON", needsConfirmation = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming 'Remove member' confirmation emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
state = state,
|
||||
)
|
||||
// Note: the string key semantics is not perfect here :/
|
||||
rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@@ -116,7 +168,7 @@ class RoomMembersModerationViewTest {
|
||||
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
|
||||
// Give time for the bottom sheet to animate
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,7 +177,7 @@ class RoomMembersModerationViewTest {
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
||||
banUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
@@ -136,13 +188,29 @@ class RoomMembersModerationViewTest {
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming 'Remove and ban member' reason edition emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
banUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
state = state,
|
||||
)
|
||||
rule.onNodeWithText(A_REASON).performTextInput("z")
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "z$A_REASON", needsConfirmation = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||
val roomMember = anAlice()
|
||||
val state = aRoomMembersModerationState(
|
||||
selectedRoomMember = roomMember,
|
||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
||||
banUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setRoomMembersModerationView(
|
||||
@@ -150,7 +218,7 @@ class RoomMembersModerationViewTest {
|
||||
)
|
||||
// Note: the string key semantics is not perfect here :/
|
||||
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
|
||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -38,6 +38,7 @@ fun ListDialog(
|
||||
cancelText: String = stringResource(CommonStrings.action_cancel),
|
||||
submitText: String = stringResource(CommonStrings.action_ok),
|
||||
enabled: Boolean = true,
|
||||
applyPaddingToContents: Boolean = false,
|
||||
listItems: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
||||
@@ -61,6 +62,7 @@ fun ListDialog(
|
||||
onSubmitClick = onSubmit,
|
||||
enabled = enabled,
|
||||
listItems = listItems,
|
||||
applyPaddingToContents = applyPaddingToContents,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,8 +74,9 @@ private fun ListDialogContent(
|
||||
onSubmitClick: () -> Unit,
|
||||
cancelText: String,
|
||||
submitText: String,
|
||||
title: String? = null,
|
||||
enabled: Boolean = true,
|
||||
title: String?,
|
||||
enabled: Boolean,
|
||||
applyPaddingToContents: Boolean,
|
||||
subtitle: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
SimpleAlertDialogContent(
|
||||
@@ -84,10 +87,12 @@ private fun ListDialogContent(
|
||||
onCancelClick = onDismissRequest,
|
||||
onSubmitClick = onSubmitClick,
|
||||
enabled = enabled,
|
||||
applyPaddingToContents = false,
|
||||
applyPaddingToContents = applyPaddingToContents,
|
||||
) {
|
||||
// No start padding if padding is already applied to the content
|
||||
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
modifier = Modifier.padding(horizontal = horizontalPadding)
|
||||
) { listItems() }
|
||||
}
|
||||
}
|
||||
@@ -111,6 +116,8 @@ internal fun ListDialogContentPreview() {
|
||||
onSubmitClick = {},
|
||||
cancelText = "Cancel",
|
||||
submitText = "Save",
|
||||
enabled = true,
|
||||
applyPaddingToContents = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ fun TextFieldListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
error: String? = null,
|
||||
maxLines: Int = 1,
|
||||
withBorder: Boolean = false,
|
||||
label: String? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
@@ -38,12 +40,17 @@ fun TextFieldListItem(
|
||||
value = text,
|
||||
onValueChange = { onTextChange(it) },
|
||||
placeholder = placeholder?.let { @Composable { Text(it) } },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
disabledBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
label = label?.let { @Composable { Text(it) } },
|
||||
colors = if (withBorder) {
|
||||
OutlinedTextFieldDefaults.colors()
|
||||
} else {
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
disabledBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
)
|
||||
},
|
||||
isError = error != null,
|
||||
supportingText = error?.let { @Composable { Text(it) } },
|
||||
keyboardOptions = keyboardOptions,
|
||||
@@ -124,3 +131,31 @@ internal fun TextFieldListItemTextFieldValuePreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview("Text field List item with border - empty", group = PreviewGroup.ListItems)
|
||||
@Composable
|
||||
internal fun TextFieldListItemWithBorderEmptyPreview() {
|
||||
ElementThemedPreview {
|
||||
TextFieldListItem(
|
||||
placeholder = "Placeholder",
|
||||
label = "Label",
|
||||
text = "",
|
||||
withBorder = true,
|
||||
onTextChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview("Text field List item with border - text", group = PreviewGroup.ListItems)
|
||||
@Composable
|
||||
internal fun TextFieldListItemWithBorderPreview() {
|
||||
ElementThemedPreview {
|
||||
TextFieldListItem(
|
||||
placeholder = "Placeholder",
|
||||
label = "Label",
|
||||
text = "Text",
|
||||
withBorder = true,
|
||||
onTextChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ const val A_MESSAGE = "Hello world!"
|
||||
const val A_REPLY = "OK, I'll be there!"
|
||||
const val ANOTHER_MESSAGE = "Hello universe!"
|
||||
const val A_CAPTION = "A media caption"
|
||||
const val A_REASON = "A reason"
|
||||
|
||||
const val A_REDACTION_REASON = "A redaction reason"
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ Reason: %1$s."</string>
|
||||
<string name="common_privacy_policy">"Privacy policy"</string>
|
||||
<string name="common_reaction">"Reaction"</string>
|
||||
<string name="common_reactions">"Reactions"</string>
|
||||
<string name="common_reason">"Reason"</string>
|
||||
<string name="common_recovery_key">"Recovery key"</string>
|
||||
<string name="common_refreshing">"Refreshing…"</string>
|
||||
<string name="common_replying_to">"Replying to %1$s"</string>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user