Improve TextFieldDialog (#4512)

* Extract TextFieldDialog to its own file (no other change).

* Add TextFieldDialogPreview

Enhance TextFieldDialog

* Let RoomMembersModerationView use TextFieldDialog

* Update screenshots

* Konsist.

* Add modifier parameter.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-04-02 10:05:56 +02:00 committed by GitHub
parent 5c2a069c95
commit 1fdb590ece
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 291 additions and 264 deletions

View file

@ -1,14 +0,0 @@
/*
* 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

View file

@ -12,8 +12,10 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
data class BanUser(val reason: String, val needsConfirmation: Boolean) : 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

@ -96,24 +96,22 @@ class RoomMembersModerationPresenter @Inject constructor(
}
}
is RoomMembersModerationEvents.KickUser -> {
if (event.needsConfirmation) {
kickUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
selectedMember?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is RoomMembersModerationEvents.DoKickUser -> {
selectedMember?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
if (event.needsConfirmation) {
banUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
selectedMember?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = null
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

View file

@ -39,11 +39,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason("A reason"),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
@ -51,11 +47,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason("A reason"),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),

View file

@ -38,9 +38,8 @@ 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.dialogs.TextFieldDialog
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
@ -74,10 +73,10 @@ fun RoomMembersModerationView(
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
state.eventSink(RoomMembersModerationEvents.KickUser)
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
state.eventSink(RoomMembersModerationEvents.BanUser)
}
}
},
@ -90,45 +89,19 @@ fun RoomMembersModerationView(
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,
)
)
},
)
}
}
}
TextFieldDialog(
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 = { reason ->
state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
content = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
@ -156,45 +129,19 @@ fun RoomMembersModerationView(
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
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,
)
)
},
)
}
}
}
TextFieldDialog(
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 = { reason ->
state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {

View file

@ -173,15 +173,11 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
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))
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
confirmingState.eventSink(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty()
@ -215,15 +211,11 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
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))
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
confirmingState.eventSink(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
skipItems(1)
val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty()
@ -314,7 +306,7 @@ class RoomMembersModerationPresenterTest {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false))
awaitItem().eventSink(RoomMembersModerationEvents.DoKickUser(reason = ""))
skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
@ -324,7 +316,7 @@ class RoomMembersModerationPresenterTest {
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false))
awaitItem().eventSink(RoomMembersModerationEvents.DoBanUser(reason = ""))
skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)

View file

@ -15,6 +15,7 @@ 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
@ -94,7 +95,7 @@ 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(reason = "", needsConfirmation = true))
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
}
@Test
@ -103,7 +104,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@ -115,19 +116,21 @@ class RoomMembersModerationViewTest {
}
@Test
fun `confirming 'Remove member' reason edition emits the expected event`() {
fun `confirming 'Remove member' reason edition then validation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "z$A_REASON", needsConfirmation = true))
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
}
@Test
@ -136,7 +139,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@ -144,7 +147,7 @@ class RoomMembersModerationViewTest {
)
// 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))
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = ""))
}
@Config(qualifiers = "h1024dp")
@ -168,7 +171,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(reason = "", needsConfirmation = true))
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
}
@Test
@ -177,7 +180,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@ -194,14 +197,16 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "z$A_REASON", needsConfirmation = true))
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
}
@Test
@ -210,7 +215,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@ -218,7 +223,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(reason = A_REASON, needsConfirmation = false))
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = ""))
}
@Test