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:
parent
1eff15d6e4
commit
0545c56486
39 changed files with 367 additions and 91 deletions
|
|
@ -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 {
|
sealed interface RoomMembersModerationEvents {
|
||||||
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
|
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
|
||||||
data object KickUser : RoomMembersModerationEvents
|
data class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
|
||||||
data object BanUser : RoomMembersModerationEvents
|
data class BanUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
|
||||||
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
|
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
|
||||||
data object Reset : RoomMembersModerationEvents
|
data object Reset : RoomMembersModerationEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,19 +96,23 @@ class RoomMembersModerationPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is RoomMembersModerationEvents.KickUser -> {
|
is RoomMembersModerationEvents.KickUser -> {
|
||||||
selectedMember?.let {
|
if (event.needsConfirmation) {
|
||||||
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
|
kickUserAsyncAction.value = ConfirmingWithReason(event.reason)
|
||||||
}
|
} else {
|
||||||
selectedMember = null
|
|
||||||
}
|
|
||||||
is RoomMembersModerationEvents.BanUser -> {
|
|
||||||
if (banUserAsyncAction.value.isConfirming()) {
|
|
||||||
selectedMember?.let {
|
selectedMember?.let {
|
||||||
coroutineScope.banUser(it.userId, banUserAsyncAction)
|
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
|
||||||
}
|
}
|
||||||
selectedMember = null
|
selectedMember = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RoomMembersModerationEvents.BanUser -> {
|
||||||
|
if (event.needsConfirmation) {
|
||||||
|
banUserAsyncAction.value = ConfirmingWithReason(event.reason)
|
||||||
} else {
|
} else {
|
||||||
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
|
selectedMember?.let {
|
||||||
|
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
|
||||||
|
}
|
||||||
|
selectedMember = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is RoomMembersModerationEvents.UnbanUser -> {
|
is RoomMembersModerationEvents.UnbanUser -> {
|
||||||
|
|
@ -138,18 +142,26 @@ class RoomMembersModerationPresenter @Inject constructor(
|
||||||
|
|
||||||
private fun CoroutineScope.kickUser(
|
private fun CoroutineScope.kickUser(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
|
reason: String,
|
||||||
kickUserAction: MutableState<AsyncAction<Unit>>,
|
kickUserAction: MutableState<AsyncAction<Unit>>,
|
||||||
) = runActionAndWaitForMembershipChange(kickUserAction) {
|
) = runActionAndWaitForMembershipChange(kickUserAction) {
|
||||||
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
|
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
|
||||||
room.kickUser(userId)
|
room.kickUser(
|
||||||
|
userId = userId,
|
||||||
|
reason = reason.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.banUser(
|
private fun CoroutineScope.banUser(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
|
reason: String,
|
||||||
banUserAction: MutableState<AsyncAction<Unit>>,
|
banUserAction: MutableState<AsyncAction<Unit>>,
|
||||||
) = runActionAndWaitForMembershipChange(banUserAction) {
|
) = runActionAndWaitForMembershipChange(banUserAction) {
|
||||||
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
|
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
|
||||||
room.banUser(userId)
|
room.banUser(
|
||||||
|
userId = userId,
|
||||||
|
reason = reason.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.unbanUser(
|
private fun CoroutineScope.unbanUser(
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,26 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
|
||||||
ModerationAction.BanUser(userId = anAlice().userId),
|
ModerationAction.BanUser(userId = anAlice().userId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
aRoomMembersModerationState(
|
||||||
|
selectedRoomMember = anAlice(),
|
||||||
|
kickUserAsyncAction = ConfirmingWithReason(""),
|
||||||
|
),
|
||||||
|
aRoomMembersModerationState(
|
||||||
|
selectedRoomMember = anAlice(),
|
||||||
|
kickUserAsyncAction = ConfirmingWithReason("A reason"),
|
||||||
|
),
|
||||||
aRoomMembersModerationState(
|
aRoomMembersModerationState(
|
||||||
selectedRoomMember = anAlice(),
|
selectedRoomMember = anAlice(),
|
||||||
kickUserAsyncAction = AsyncAction.Loading,
|
kickUserAsyncAction = AsyncAction.Loading,
|
||||||
),
|
),
|
||||||
|
aRoomMembersModerationState(
|
||||||
|
selectedRoomMember = anAlice(),
|
||||||
|
banUserAsyncAction = ConfirmingWithReason(""),
|
||||||
|
),
|
||||||
|
aRoomMembersModerationState(
|
||||||
|
selectedRoomMember = anAlice(),
|
||||||
|
banUserAsyncAction = ConfirmingWithReason("A reason"),
|
||||||
|
),
|
||||||
aRoomMembersModerationState(
|
aRoomMembersModerationState(
|
||||||
selectedRoomMember = anAlice(),
|
selectedRoomMember = anAlice(),
|
||||||
banUserAsyncAction = AsyncAction.Loading,
|
banUserAsyncAction = AsyncAction.Loading,
|
||||||
|
|
@ -54,10 +70,6 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
|
||||||
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
|
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
|
||||||
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
|
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
|
||||||
),
|
),
|
||||||
aRoomMembersModerationState(
|
|
||||||
selectedRoomMember = anAlice(),
|
|
||||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
|
||||||
),
|
|
||||||
aRoomMembersModerationState(
|
aRoomMembersModerationState(
|
||||||
selectedRoomMember = anAlice(),
|
selectedRoomMember = anAlice(),
|
||||||
unbanUserAsyncAction = ConfirmingRoomMemberAction(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.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
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.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.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.IconSource
|
||||||
|
|
@ -72,10 +74,10 @@ fun RoomMembersModerationView(
|
||||||
onDisplayMemberProfile(action.userId)
|
onDisplayMemberProfile(action.userId)
|
||||||
}
|
}
|
||||||
is ModerationAction.KickUser -> {
|
is ModerationAction.KickUser -> {
|
||||||
state.eventSink(RoomMembersModerationEvents.KickUser)
|
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
|
||||||
}
|
}
|
||||||
is ModerationAction.BanUser -> {
|
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)
|
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
|
||||||
|
|
||||||
when (val action = state.kickUserAsyncAction) {
|
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 -> {
|
is AsyncAction.Loading -> {
|
||||||
LaunchedEffect(action) {
|
LaunchedEffect(action) {
|
||||||
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
|
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
|
||||||
|
|
@ -113,13 +156,45 @@ fun RoomMembersModerationView(
|
||||||
|
|
||||||
when (val action = state.banUserAsyncAction) {
|
when (val action = state.banUserAsyncAction) {
|
||||||
is AsyncAction.Confirming -> {
|
is AsyncAction.Confirming -> {
|
||||||
ConfirmationDialog(
|
if (action is ConfirmingWithReason) {
|
||||||
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
|
ListDialog(
|
||||||
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
|
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
|
||||||
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
|
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
|
||||||
onSubmitClick = { state.eventSink(RoomMembersModerationEvents.BanUser) },
|
onSubmit = {
|
||||||
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
|
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 -> {
|
is AsyncAction.Loading -> {
|
||||||
LaunchedEffect(action) {
|
LaunchedEffect(action) {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@
|
||||||
<item quantity="one">"%1$d person"</item>
|
<item quantity="one">"%1$d person"</item>
|
||||||
<item quantity="other">"%1$d people"</item>
|
<item quantity="other">"%1$d people"</item>
|
||||||
</plurals>
|
</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_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">"Remove from room"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Remove and ban member"</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.features.roomdetails.impl.members.aVictor
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
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.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
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
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.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
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.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -153,13 +157,14 @@ class RoomMembersModerationPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Kick removes the user`() = runTest {
|
fun `present - Kick requires confirmation and then kicks the user`() = runTest {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val kickUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||||
val room = aMatrixRoom(
|
val room = aMatrixRoom(
|
||||||
canKickResult = { Result.success(true) },
|
canKickResult = { Result.success(true) },
|
||||||
canBanResult = { Result.success(true) },
|
canBanResult = { Result.success(true) },
|
||||||
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
||||||
kickUserResult = { _, _ -> Result.success(Unit) },
|
kickUserResult = kickUserResult,
|
||||||
)
|
)
|
||||||
val selectedMember = aVictor()
|
val selectedMember = aVictor()
|
||||||
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||||
|
|
@ -168,7 +173,15 @@ class RoomMembersModerationPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
|
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)
|
skipItems(1)
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.actions).isEmpty()
|
assertThat(loadingState.actions).isEmpty()
|
||||||
|
|
@ -178,17 +191,22 @@ class RoomMembersModerationPresenterTest {
|
||||||
assertThat(selectedRoomMember).isNull()
|
assertThat(selectedRoomMember).isNull()
|
||||||
}
|
}
|
||||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
|
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
|
||||||
|
kickUserResult.assertions().isCalledOnce().with(
|
||||||
|
value(selectedMember.userId),
|
||||||
|
value(A_REASON),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
|
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val banUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||||
val room = aMatrixRoom(
|
val room = aMatrixRoom(
|
||||||
canKickResult = { Result.success(true) },
|
canKickResult = { Result.success(true) },
|
||||||
canBanResult = { Result.success(true) },
|
canBanResult = { Result.success(true) },
|
||||||
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
|
||||||
banUserResult = { _, _ -> Result.success(Unit) },
|
banUserResult = banUserResult,
|
||||||
)
|
)
|
||||||
val selectedMember = aVictor()
|
val selectedMember = aVictor()
|
||||||
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||||
|
|
@ -197,12 +215,15 @@ class RoomMembersModerationPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
|
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
|
||||||
val confirmingState = awaitItem()
|
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
|
// Confirm
|
||||||
confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
|
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadingItem = awaitItem()
|
val loadingItem = awaitItem()
|
||||||
assertThat(loadingItem.actions).isEmpty()
|
assertThat(loadingItem.actions).isEmpty()
|
||||||
|
|
@ -213,6 +234,10 @@ class RoomMembersModerationPresenterTest {
|
||||||
assertThat(selectedRoomMember).isNull()
|
assertThat(selectedRoomMember).isNull()
|
||||||
}
|
}
|
||||||
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
|
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()
|
val initialItem = awaitItem()
|
||||||
// Kick user and fail
|
// Kick user and fail
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
|
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
|
|
@ -299,8 +324,7 @@ class RoomMembersModerationPresenterTest {
|
||||||
|
|
||||||
// Ban user and fail
|
// Ban user and fail
|
||||||
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false))
|
||||||
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::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.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.features.roomdetails.impl.R
|
import io.element.android.features.roomdetails.impl.R
|
||||||
import io.element.android.features.roomdetails.impl.members.anAlice
|
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.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.test.A_REASON
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
|
@ -92,7 +94,57 @@ class RoomMembersModerationViewTest {
|
||||||
rule.clickOn(R.string.screen_room_member_list_manage_member_remove)
|
rule.clickOn(R.string.screen_room_member_list_manage_member_remove)
|
||||||
// Give time for the bottom sheet to animate
|
// Give time for the bottom sheet to animate
|
||||||
rule.mainClock.advanceTimeBy(1_000)
|
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")
|
@Config(qualifiers = "h1024dp")
|
||||||
|
|
@ -116,7 +168,7 @@ class RoomMembersModerationViewTest {
|
||||||
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
|
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
|
||||||
// Give time for the bottom sheet to animate
|
// Give time for the bottom sheet to animate
|
||||||
rule.mainClock.advanceTimeBy(1_000)
|
rule.mainClock.advanceTimeBy(1_000)
|
||||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
|
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -125,7 +177,7 @@ class RoomMembersModerationViewTest {
|
||||||
val roomMember = anAlice()
|
val roomMember = anAlice()
|
||||||
val state = aRoomMembersModerationState(
|
val state = aRoomMembersModerationState(
|
||||||
selectedRoomMember = roomMember,
|
selectedRoomMember = roomMember,
|
||||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
banUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
)
|
)
|
||||||
rule.setRoomMembersModerationView(
|
rule.setRoomMembersModerationView(
|
||||||
|
|
@ -136,13 +188,29 @@ class RoomMembersModerationViewTest {
|
||||||
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
|
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
|
@Test
|
||||||
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
|
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
|
||||||
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
|
||||||
val roomMember = anAlice()
|
val roomMember = anAlice()
|
||||||
val state = aRoomMembersModerationState(
|
val state = aRoomMembersModerationState(
|
||||||
selectedRoomMember = roomMember,
|
selectedRoomMember = roomMember,
|
||||||
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
|
banUserAsyncAction = ConfirmingWithReason(A_REASON),
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
)
|
)
|
||||||
rule.setRoomMembersModerationView(
|
rule.setRoomMembersModerationView(
|
||||||
|
|
@ -150,7 +218,7 @@ class RoomMembersModerationViewTest {
|
||||||
)
|
)
|
||||||
// Note: the string key semantics is not perfect here :/
|
// Note: the string key semantics is not perfect here :/
|
||||||
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
|
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
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ fun ListDialog(
|
||||||
cancelText: String = stringResource(CommonStrings.action_cancel),
|
cancelText: String = stringResource(CommonStrings.action_cancel),
|
||||||
submitText: String = stringResource(CommonStrings.action_ok),
|
submitText: String = stringResource(CommonStrings.action_ok),
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
applyPaddingToContents: Boolean = false,
|
||||||
listItems: LazyListScope.() -> Unit,
|
listItems: LazyListScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
||||||
|
|
@ -61,6 +62,7 @@ fun ListDialog(
|
||||||
onSubmitClick = onSubmit,
|
onSubmitClick = onSubmit,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
listItems = listItems,
|
listItems = listItems,
|
||||||
|
applyPaddingToContents = applyPaddingToContents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,8 +74,9 @@ private fun ListDialogContent(
|
||||||
onSubmitClick: () -> Unit,
|
onSubmitClick: () -> Unit,
|
||||||
cancelText: String,
|
cancelText: String,
|
||||||
submitText: String,
|
submitText: String,
|
||||||
title: String? = null,
|
title: String?,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean,
|
||||||
|
applyPaddingToContents: Boolean,
|
||||||
subtitle: @Composable (() -> Unit)? = null,
|
subtitle: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
SimpleAlertDialogContent(
|
SimpleAlertDialogContent(
|
||||||
|
|
@ -84,10 +87,12 @@ private fun ListDialogContent(
|
||||||
onCancelClick = onDismissRequest,
|
onCancelClick = onDismissRequest,
|
||||||
onSubmitClick = onSubmitClick,
|
onSubmitClick = onSubmitClick,
|
||||||
enabled = enabled,
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
modifier = Modifier.padding(horizontal = horizontalPadding)
|
||||||
) { listItems() }
|
) { listItems() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +116,8 @@ internal fun ListDialogContentPreview() {
|
||||||
onSubmitClick = {},
|
onSubmitClick = {},
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
submitText = "Save",
|
submitText = "Save",
|
||||||
|
enabled = true,
|
||||||
|
applyPaddingToContents = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ fun TextFieldListItem(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
maxLines: Int = 1,
|
maxLines: Int = 1,
|
||||||
|
withBorder: Boolean = false,
|
||||||
|
label: String? = null,
|
||||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
|
|
@ -38,12 +40,17 @@ fun TextFieldListItem(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { onTextChange(it) },
|
onValueChange = { onTextChange(it) },
|
||||||
placeholder = placeholder?.let { @Composable { Text(it) } },
|
placeholder = placeholder?.let { @Composable { Text(it) } },
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
label = label?.let { @Composable { Text(it) } },
|
||||||
disabledBorderColor = Color.Transparent,
|
colors = if (withBorder) {
|
||||||
errorBorderColor = Color.Transparent,
|
OutlinedTextFieldDefaults.colors()
|
||||||
focusedBorderColor = Color.Transparent,
|
} else {
|
||||||
unfocusedBorderColor = Color.Transparent,
|
OutlinedTextFieldDefaults.colors(
|
||||||
),
|
disabledBorderColor = Color.Transparent,
|
||||||
|
errorBorderColor = Color.Transparent,
|
||||||
|
focusedBorderColor = Color.Transparent,
|
||||||
|
unfocusedBorderColor = Color.Transparent,
|
||||||
|
)
|
||||||
|
},
|
||||||
isError = error != null,
|
isError = error != null,
|
||||||
supportingText = error?.let { @Composable { Text(it) } },
|
supportingText = error?.let { @Composable { Text(it) } },
|
||||||
keyboardOptions = keyboardOptions,
|
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 A_REPLY = "OK, I'll be there!"
|
||||||
const val ANOTHER_MESSAGE = "Hello universe!"
|
const val ANOTHER_MESSAGE = "Hello universe!"
|
||||||
const val A_CAPTION = "A media caption"
|
const val A_CAPTION = "A media caption"
|
||||||
|
const val A_REASON = "A reason"
|
||||||
|
|
||||||
const val A_REDACTION_REASON = "A redaction 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_privacy_policy">"Privacy policy"</string>
|
||||||
<string name="common_reaction">"Reaction"</string>
|
<string name="common_reaction">"Reaction"</string>
|
||||||
<string name="common_reactions">"Reactions"</string>
|
<string name="common_reactions">"Reactions"</string>
|
||||||
|
<string name="common_reason">"Reason"</string>
|
||||||
<string name="common_recovery_key">"Recovery key"</string>
|
<string name="common_recovery_key">"Recovery key"</string>
|
||||||
<string name="common_refreshing">"Refreshing…"</string>
|
<string name="common_refreshing">"Refreshing…"</string>
|
||||||
<string name="common_replying_to">"Replying to %1$s"</string>
|
<string name="common_replying_to">"Replying to %1$s"</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f83d60c7e1d963ccef24668c219bd9df36494dc8bd8f57af7aa63a3b73ee6882
|
||||||
|
size 7545
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b323796ccdf27e5c445aa02e3f7b73f65df961dd88f3ee480efcba14d8038281
|
||||||
|
size 19426
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||||
|
size 3642
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2d06a58bb3b3fe7b01740cc04ed3128d24a13717be84cb4a70ced228d2256b41
|
oid sha256:b9a5256bf2af8a8e9ee87bbab2ebdc7cd5823bc6b2cfde6267a68bd797e6eb61
|
||||||
size 9435
|
size 29650
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:571207325446195a58251e7ece1ecaabc8a782cbb526315abe2a82f21e6441eb
|
oid sha256:501ebaae1e09a4f7853b574b788ddb3a73cd470d37d5dbef1f46b2967761e612
|
||||||
size 9006
|
size 31207
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
oid sha256:2d06a58bb3b3fe7b01740cc04ed3128d24a13717be84cb4a70ced228d2256b41
|
||||||
size 3642
|
size 9435
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f83d60c7e1d963ccef24668c219bd9df36494dc8bd8f57af7aa63a3b73ee6882
|
oid sha256:39b34e0940f38ab0d66ba68b61c07ba70a51acfb54f995eedce800231ea6a2c5
|
||||||
size 7545
|
size 28257
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:16685ff4d5d52c5cb15f87fd14178cf791a85f3295b9869f997f4dac9fda1373
|
oid sha256:96e0f2e3c32eedf2c85e243661a7947d28cbfe2eee7df81b239c5331770e7f7c
|
||||||
size 25819
|
size 29835
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b323796ccdf27e5c445aa02e3f7b73f65df961dd88f3ee480efcba14d8038281
|
oid sha256:571207325446195a58251e7ece1ecaabc8a782cbb526315abe2a82f21e6441eb
|
||||||
size 19426
|
size 9006
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ba213b12d181b04695b9271670cb7d96ce83dfccc3c2031ee002ba1cf180cc8d
|
||||||
|
size 6408
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f9def02bcb5784681dc26327791510a9a25f0772644cdae363d7a4b90e69067c
|
||||||
|
size 17479
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||||
|
size 3659
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:273043114950e000953fbede780f3ad8adcce9d3d579edea2cad58f8e3c48881
|
oid sha256:e07ab4816ff009eaa26e1f3cf69b613a9a34baea0dd55e0eface745ee7328758
|
||||||
size 8175
|
size 27486
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2b0d51e2c5b255fb2f94959734293d4b7807f002acb0217c84856f0002297293
|
oid sha256:83e72d7332d7574dd2f4ea42b9fa18995359720b18105f08bfde1f7f14cf8725
|
||||||
size 7636
|
size 29080
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
oid sha256:273043114950e000953fbede780f3ad8adcce9d3d579edea2cad58f8e3c48881
|
||||||
size 3659
|
size 8175
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ba213b12d181b04695b9271670cb7d96ce83dfccc3c2031ee002ba1cf180cc8d
|
oid sha256:19483b4fae4026098273e6e4c941592074f08b3e7f9fa44e759bdab9daf2b27a
|
||||||
size 6408
|
size 26233
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dfb80116e111dcc2fff42ea4a6c26e2857eecfbfcdb6a43d09aaaaf63a990fca
|
oid sha256:0c36a1bfdfdf92ac180436b8c4aef3d017911119f10f02e2a940b5ab242ac324
|
||||||
size 24019
|
size 27856
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f9def02bcb5784681dc26327791510a9a25f0772644cdae363d7a4b90e69067c
|
oid sha256:2b0d51e2c5b255fb2f94959734293d4b7807f002acb0217c84856f0002297293
|
||||||
size 17479
|
size 7636
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:24d042c0c4e9f9d01c5937d42cf16d05ad24e2d8cfd8026038126531b8304cd4
|
oid sha256:114c59fd334979528721e0409e22096a058f965b0cfb5b7860816590d7c84652
|
||||||
size 30195
|
size 29924
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:390153008f59d4915895498d3d6fa3354502f3f3a902b3ae5e53045aec27432d
|
oid sha256:c5f9152fbdce0823fb728586486e8625f96caceb23b69685680313b62a81c1a8
|
||||||
size 18116
|
size 18073
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3ad10053328a529721e5f6564e493e4d0ea1d9e4e098867f1a4daa938396d712
|
oid sha256:1d324a3cf4090c18459f06753d28bee13102457da07ef17358a7d65797df965b
|
||||||
size 16273
|
size 16234
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7dc297457b140b0bcf72fb3de3e2fd8061983ab70ad2d5299cc5ba85d5a7db55
|
||||||
|
size 7270
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4a3648b24b1640410c19eb5ee5c9808c48be5cb2dc0862d84f8419289988fd39
|
||||||
|
size 8708
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2d966380d44d4fdfa87c17135cb540aec7b5e5d9dd433ddb9945d088b46eb76a
|
oid sha256:f4393abc83861f1ab67e0637d7192887c31d94ecad9642691bcfe87e68df3a42
|
||||||
size 13485
|
size 13422
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:808978903883f938d08c594c9a87f97774f9a0c11847088a623e199809882adb
|
oid sha256:57c8c7fa3b993fc52c3fbc2014252e8fff1395f855d6f4acd1681d843eedf2c0
|
||||||
size 11916
|
size 11825
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ce993fc311dd889c5a709941cec6224af603f3e9db62c02eda36adcb43d9c8d2
|
oid sha256:57d2c9529a6cef0b9a009710645f92d50f93563f4f765994f6f006ef4db1d5c4
|
||||||
size 14697
|
size 14684
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4e256f644dd8cbb96d0d97c0f41e036f41f137cd09e322da9807ad7271943a6a
|
oid sha256:a43a5ebccf177014e2d7726e9a0f0cf4970252b7301843b98c5ae7e72dc34aee
|
||||||
size 12999
|
size 12955
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue