Move the "Blocked users" item to the Moderation and safety screen.

The item is removed from the account section of the root settings screen and
added at the bottom of the Moderation and safety screen. As before, it is only
shown when at least one user is blocked, with the number of blocked users.

ModerationAndSafetyPresenter now computes the number of blocked users, and
ModerationAndSafetyNode exposes a Callback to navigate to the blocked users
screen. Tests are moved accordingly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-23 16:14:49 +02:00
committed by Benoit Marty
parent c612c80b1d
commit 01681e9fff
19 changed files with 122 additions and 84 deletions
@@ -193,10 +193,6 @@ class PreferencesFlowNode(
backstack.push(NavTarget.UserProfile(matrixUser)) backstack.push(NavTarget.UserProfile(matrixUser))
} }
override fun navigateToBlockedUsers() {
backstack.push(NavTarget.BlockedUsers)
}
override fun startSignOutFlow() { override fun startSignOutFlow() {
backstack.push(NavTarget.SignOut) backstack.push(NavTarget.SignOut)
} }
@@ -236,7 +232,12 @@ class PreferencesFlowNode(
createNode<LabsNode>(buildContext, listOf(callback)) createNode<LabsNode>(buildContext, listOf(callback))
} }
NavTarget.ModerationAndSafety -> { NavTarget.ModerationAndSafety -> {
createNode<ModerationAndSafetyNode>(buildContext) val callback = object : ModerationAndSafetyNode.Callback {
override fun navigateToBlockedUsers() {
backstack.push(NavTarget.BlockedUsers)
}
}
createNode<ModerationAndSafetyNode>(buildContext, listOf(callback))
} }
NavTarget.LocationSettings -> { NavTarget.LocationSettings -> {
createNode<LocationSettingsNode>(buildContext) createNode<LocationSettingsNode>(buildContext)
@@ -16,7 +16,6 @@ interface PreferencesAccountCallback {
fun navigateToNotificationSettings() fun navigateToNotificationSettings()
fun navigateToLinkNewDevice() fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser) fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow() fun startSignOutFlow()
fun startAccountDeactivationFlow() fun startAccountDeactivationFlow()
} }
@@ -27,8 +27,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Inject @Inject
class PreferencesAccountPresenter( class PreferencesAccountPresenter(
@@ -67,12 +65,6 @@ class PreferencesAccountPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount() canDeactivateAccount = matrixClient.canDeactivateAccount()
} }
val numberOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.size }
.launchIn(this)
}
val directLogoutState = directLogoutPresenter.present() val directLogoutState = directLogoutPresenter.present()
val accountManagementUrl by produceState<String?>(initialValue = null) { val accountManagementUrl by produceState<String?>(initialValue = null) {
@@ -91,7 +83,6 @@ class PreferencesAccountPresenter(
canReportBug = canReportBug, canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice, showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount, canDeactivateAccount = canDeactivateAccount,
numberOfBlockedUsers = numberOfBlockedUsers,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
@@ -19,9 +19,6 @@ data class PreferencesAccountState(
val canReportBug: Boolean, val canReportBug: Boolean,
val showLinkNewDevice: Boolean, val showLinkNewDevice: Boolean,
val canDeactivateAccount: Boolean, val canDeactivateAccount: Boolean,
val numberOfBlockedUsers: Int,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
) { )
val showBlockedUsersItem = numberOfBlockedUsers > 0
}
@@ -26,7 +26,6 @@ open class PreferencesAccountStatePreviewParam : PreviewParameterProvider<Prefer
canReportBug = true, canReportBug = true,
showLinkNewDevice = true, showLinkNewDevice = true,
canDeactivateAccount = false, canDeactivateAccount = false,
numberOfBlockedUsers = 3,
), ),
aPreferencesAccountState( aPreferencesAccountState(
myUser = aMatrixUser(displayName = null), myUser = aMatrixUser(displayName = null),
@@ -40,7 +39,6 @@ open class PreferencesAccountStatePreviewParam : PreviewParameterProvider<Prefer
), ),
aPreferencesAccountState( aPreferencesAccountState(
canReportBug = true, canReportBug = true,
numberOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
), ),
aPreferencesAccountState( aPreferencesAccountState(
@@ -58,7 +56,6 @@ fun aPreferencesAccountState(
canReportBug: Boolean = false, canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false, showLinkNewDevice: Boolean = false,
canDeactivateAccount: Boolean = false, canDeactivateAccount: Boolean = false,
numberOfBlockedUsers: Int = 0,
directLogoutState: DirectLogoutState = aDirectLogoutState(), directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
) = PreferencesAccountState( ) = PreferencesAccountState(
@@ -69,7 +66,6 @@ fun aPreferencesAccountState(
canReportBug = canReportBug, canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice, showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount, canDeactivateAccount = canDeactivateAccount,
numberOfBlockedUsers = numberOfBlockedUsers,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
@@ -35,7 +35,6 @@ fun PreferencesAccountView(
onOpenRageShake: () -> Unit, onOpenRageShake: () -> Unit,
onModerationAndSafetyClick: () -> Unit, onModerationAndSafetyClick: () -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClick: () -> Unit, onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit, onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -46,7 +45,6 @@ fun PreferencesAccountView(
state = state, state = state,
onManageAccountClick = onManageAccountClick, onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick, onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers,
) )
OtherSettingsSection( OtherSettingsSection(
state = state, state = state,
@@ -69,7 +67,6 @@ private fun ManageAccountSection(
state: PreferencesAccountState, state: PreferencesAccountState,
onManageAccountClick: (url: String) -> Unit, onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit, onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) { ) {
PreferenceCategory( PreferenceCategory(
title = stringResource(CommonStrings.common_account_settings), title = stringResource(CommonStrings.common_account_settings),
@@ -90,14 +87,6 @@ private fun ManageAccountSection(
onClick = onLinkNewDeviceClick, onClick = onLinkNewDeviceClick,
) )
} }
if (state.showBlockedUsersItem) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.numberOfBlockedUsers.toString()),
)
}
} }
} }
@@ -173,9 +162,7 @@ internal fun PreferencesAccountViewPreview(@PreviewParameter(PreferencesAccountS
onManageAccountClick = {}, onManageAccountClick = {},
onLinkNewDeviceClick = {}, onLinkNewDeviceClick = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {}, onSignOutClick = {},
onDeactivateClick = {}, onDeactivateClick = {},
) )
} }
@@ -15,6 +15,7 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@@ -24,6 +25,12 @@ class ModerationAndSafetyNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: ModerationAndSafetyPresenter, private val presenter: ModerationAndSafetyPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToBlockedUsers()
}
private val callback: Callback = callback()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@@ -31,6 +38,7 @@ class ModerationAndSafetyNode(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
) )
} }
} }
@@ -10,16 +10,21 @@ package io.element.android.features.preferences.impl.moderation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
class ModerationAndSafetyPresenter( class ModerationAndSafetyPresenter(
private val matrixClient: MatrixClient,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
@SessionCoroutineScope @SessionCoroutineScope
@@ -33,6 +38,12 @@ class ModerationAndSafetyPresenter(
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val numberOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.size }
.launchIn(this)
}
fun handleEvent(event: ModerationAndSafetyEvent) { fun handleEvent(event: ModerationAndSafetyEvent) {
when (event) { when (event) {
is ModerationAndSafetyEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch { is ModerationAndSafetyEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch {
@@ -46,6 +57,7 @@ class ModerationAndSafetyPresenter(
return ModerationAndSafetyState( return ModerationAndSafetyState(
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
mediaPreviewConfigState = mediaPreviewConfigState, mediaPreviewConfigState = mediaPreviewConfigState,
numberOfBlockedUsers = numberOfBlockedUsers,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -10,5 +10,8 @@ package io.element.android.features.preferences.impl.moderation
data class ModerationAndSafetyState( data class ModerationAndSafetyState(
val isSharePresenceEnabled: Boolean, val isSharePresenceEnabled: Boolean,
val mediaPreviewConfigState: MediaPreviewConfigState, val mediaPreviewConfigState: MediaPreviewConfigState,
val numberOfBlockedUsers: Int,
val eventSink: (ModerationAndSafetyEvent) -> Unit val eventSink: (ModerationAndSafetyEvent) -> Unit
) ) {
val showBlockedUsersItem = numberOfBlockedUsers > 0
}
@@ -20,6 +20,7 @@ open class ModerationAndSafetyStatePreviewParam : PreviewParameterProvider<Moder
aModerationAndSafetyState(timelineMediaPreviewValue = MediaPreviewValue.Off), aModerationAndSafetyState(timelineMediaPreviewValue = MediaPreviewValue.Off),
aModerationAndSafetyState(setHideInviteAvatarsAction = AsyncAction.Loading), aModerationAndSafetyState(setHideInviteAvatarsAction = AsyncAction.Loading),
aModerationAndSafetyState(setTimelineMediaPreviewAction = AsyncAction.Loading), aModerationAndSafetyState(setTimelineMediaPreviewAction = AsyncAction.Loading),
aModerationAndSafetyState(numberOfBlockedUsers = 3),
) )
} }
@@ -29,6 +30,7 @@ fun aModerationAndSafetyState(
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized, setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized, setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
numberOfBlockedUsers: Int = 0,
eventSink: (ModerationAndSafetyEvent) -> Unit = {}, eventSink: (ModerationAndSafetyEvent) -> Unit = {},
) = ModerationAndSafetyState( ) = ModerationAndSafetyState(
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
@@ -38,5 +40,6 @@ fun aModerationAndSafetyState(
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction setHideInviteAvatarsAction = setHideInviteAvatarsAction
), ),
numberOfBlockedUsers = numberOfBlockedUsers,
eventSink = eventSink eventSink = eventSink
) )
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -24,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewBlack
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText import io.element.android.libraries.designsystem.theme.components.ListSupportingText
@@ -43,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun ModerationAndSafetyView( fun ModerationAndSafetyView(
state: ModerationAndSafetyState, state: ModerationAndSafetyState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarDispatcher = LocalSnackbarDispatcher.current val snackbarDispatcher = LocalSnackbarDispatcher.current
@@ -63,6 +66,29 @@ fun ModerationAndSafetyView(
PresenceSection(state) PresenceSection(state)
InvitesSection(state) InvitesSection(state)
MediaSection(state) MediaSection(state)
if (state.showBlockedUsersItem) {
BlockedUsersSection(
numberOfBlockedUsers = state.numberOfBlockedUsers,
onOpenBlockedUsers = onOpenBlockedUsers,
)
}
}
}
@Composable
private fun BlockedUsersSection(
numberOfBlockedUsers: Int,
onOpenBlockedUsers: () -> Unit,
) {
PreferenceCategory(
title = stringResource(R.string.screen_moderation_and_safety_other_users_heading),
) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(numberOfBlockedUsers.toString()),
)
} }
} }
@@ -175,6 +201,7 @@ internal fun ModerationAndSafetyViewBlackPreview(@PreviewParameter(ModerationAnd
private fun ContentToPreview(state: ModerationAndSafetyState) { private fun ContentToPreview(state: ModerationAndSafetyState) {
ModerationAndSafetyView( ModerationAndSafetyView(
state = state, state = state,
onBackClick = { } onBackClick = { },
onOpenBlockedUsers = { },
) )
} }
@@ -89,7 +89,6 @@ class PreferencesRootNode(
onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings, onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile, onEditProfileClick = callback::navigateToUserProfile,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
onSignOutClick = { onSignOutClick = {
if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) { if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) {
state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
@@ -93,7 +93,6 @@ fun PreferencesRootView(
onOpenRageShake: () -> Unit, onOpenRageShake: () -> Unit,
onModerationAndSafetyClick: () -> Unit, onModerationAndSafetyClick: () -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClick: () -> Unit, onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit, onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -140,7 +139,6 @@ fun PreferencesRootView(
onOpenRageShake = onOpenRageShake, onOpenRageShake = onOpenRageShake,
onModerationAndSafetyClick = onModerationAndSafetyClick, onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick, onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick, onDeactivateClick = onDeactivateClick,
) )
@@ -441,7 +439,6 @@ private fun ContentToPreview(state: PreferencesRootState) {
onOpenRageShake = {}, onOpenRageShake = {},
onModerationAndSafetyClick = {}, onModerationAndSafetyClick = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {}, onSignOutClick = {},
onDeactivateClick = {}, onDeactivateClick = {},
) )
@@ -62,6 +62,7 @@
<string name="screen_labs_header_title">"Feeling experimental?"</string> <string name="screen_labs_header_title">"Feeling experimental?"</string>
<string name="screen_labs_title">"Labs"</string> <string name="screen_labs_title">"Labs"</string>
<string name="screen_moderation_and_safety_invites_heading">"Invites"</string> <string name="screen_moderation_and_safety_invites_heading">"Invites"</string>
<string name="screen_moderation_and_safety_other_users_heading">"Other users"</string>
<string name="screen_moderation_and_safety_share_presence_heading">"Your appearance"</string> <string name="screen_moderation_and_safety_share_presence_heading">"Your appearance"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string> <string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_call_ringtone_label">"Call ringtone"</string> <string name="screen_notification_settings_call_ringtone_label">"Call ringtone"</string>
@@ -22,8 +22,6 @@ import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_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_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -32,8 +30,6 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@@ -69,8 +65,6 @@ class PreferencesAccountPresenterTest {
assertThat(initialState.showSecureBackupBadge).isFalse() assertThat(initialState.showSecureBackupBadge).isFalse()
assertThat(initialState.accountManagementUrl).isNull() assertThat(initialState.accountManagementUrl).isNull()
assertThat(initialState.showLinkNewDevice).isFalse() assertThat(initialState.showLinkNewDevice).isFalse()
assertThat(initialState.numberOfBlockedUsers).isEqualTo(0)
assertThat(initialState.showBlockedUsersItem).isFalse()
assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last()
@@ -98,21 +92,6 @@ class PreferencesAccountPresenterTest {
} }
} }
@Test
fun `present - number of blocked users`() = runTest {
createPreferencesAccountPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success("") },
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)),
),
).test {
val state = consumeItemsUntilPredicate { it.numberOfBlockedUsers == 2 }.last()
assertThat(state.showBlockedUsersItem).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - secure backup badge`() = runTest { fun `present - secure backup badge`() = runTest {
val indicatorService = FakeIndicatorService() val indicatorService = FakeIndicatorService()
@@ -62,23 +62,6 @@ class PreferencesAccountViewTest : RobolectricTest() {
onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist()
} }
@Test
fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
setView(
aPreferencesAccountState(numberOfBlockedUsers = 1),
onOpenBlockedUsers = callback,
)
clickOn(CommonStrings.common_blocked_users)
}
}
@Test
fun `when numberOfBlockedUsers is 0, item is not shown`() = runAndroidComposeUiTest {
setView(aPreferencesAccountState(numberOfBlockedUsers = 0))
onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
}
@Test @Test
fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest { fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -177,7 +160,6 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenRageShake: () -> Unit = EnsureNeverCalled(), onOpenRageShake: () -> Unit = EnsureNeverCalled(),
onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(), onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(),
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
onSignOutClick: () -> Unit = EnsureNeverCalled(), onSignOutClick: () -> Unit = EnsureNeverCalled(),
onDeactivateClick: () -> Unit = EnsureNeverCalled(), onDeactivateClick: () -> Unit = EnsureNeverCalled(),
) { ) {
@@ -190,7 +172,6 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenRageShake = onOpenRageShake, onOpenRageShake = onOpenRageShake,
onModerationAndSafetyClick = onModerationAndSafetyClick, onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick, onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick, onDeactivateClick = onDeactivateClick,
) )
@@ -10,10 +10,16 @@ package io.element.android.features.preferences.impl.moderation
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.media.MediaPreviewValue
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.FakeMatrixClient
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -31,10 +37,25 @@ class ModerationAndSafetyPresenterTest {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(numberOfBlockedUsers).isEqualTo(0)
assertThat(showBlockedUsersItem).isFalse()
} }
} }
} }
@Test
fun `present - number of blocked users`() = runTest {
createModerationAndSafetyPresenter(
matrixClient = FakeMatrixClient(
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)),
),
).test {
val state = consumeItemsUntilPredicate { it.numberOfBlockedUsers == 2 }.last()
assertThat(state.showBlockedUsersItem).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - share presence off on`() = runTest { fun `present - share presence off on`() = runTest {
createModerationAndSafetyPresenter().test { createModerationAndSafetyPresenter().test {
@@ -121,9 +142,11 @@ class ModerationAndSafetyPresenterTest {
} }
private fun CoroutineScope.createModerationAndSafetyPresenter( private fun CoroutineScope.createModerationAndSafetyPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
) = ModerationAndSafetyPresenter( ) = ModerationAndSafetyPresenter(
matrixClient = matrixClient,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
sessionCoroutineScope = this, sessionCoroutineScope = this,
@@ -12,10 +12,16 @@ package io.element.android.features.preferences.impl.moderation
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
@@ -138,16 +144,46 @@ class ModerationAndSafetyViewTest : RobolectricTest() {
clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
} }
@Test
fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ModerationAndSafetyEvent>(expectEvents = false)
ensureCalledOnce { callback ->
setModerationAndSafetyView(
state = aModerationAndSafetyState(
numberOfBlockedUsers = 1,
eventSink = eventsRecorder,
),
onOpenBlockedUsers = callback,
)
val text = activity!!.getString(CommonStrings.common_blocked_users)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@Test
fun `when numberOfBlockedUsers is 0, Blocked users item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ModerationAndSafetyEvent>(expectEvents = false)
setModerationAndSafetyView(
state = aModerationAndSafetyState(
numberOfBlockedUsers = 0,
eventSink = eventsRecorder,
),
)
onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
}
} }
private fun AndroidComposeUiTest<ComponentActivity>.setModerationAndSafetyView( private fun AndroidComposeUiTest<ComponentActivity>.setModerationAndSafetyView(
state: ModerationAndSafetyState, state: ModerationAndSafetyState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
ModerationAndSafetyView( ModerationAndSafetyView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,
onOpenBlockedUsers = onOpenBlockedUsers,
) )
} }
} }
@@ -409,7 +409,6 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenRageShake: () -> Unit = EnsureNeverCalled(), onOpenRageShake: () -> Unit = EnsureNeverCalled(),
onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(), onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(),
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
onSignOutClick: () -> Unit = EnsureNeverCalled(), onSignOutClick: () -> Unit = EnsureNeverCalled(),
onDeactivateClick: () -> Unit = EnsureNeverCalled(), onDeactivateClick: () -> Unit = EnsureNeverCalled(),
) { ) {
@@ -433,7 +432,6 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenRageShake = onOpenRageShake, onOpenRageShake = onOpenRageShake,
onModerationAndSafetyClick = onModerationAndSafetyClick, onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick, onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick, onDeactivateClick = onDeactivateClick,
) )