Merge pull request #2930 from element-hq/feature/bma/blockedUserData

Render blocked user data (behind a disabled feature flag)
This commit is contained in:
Benoit Marty 2024-05-28 18:30:37 +02:00 committed by GitHub
commit 8e93d7933f
23 changed files with 226 additions and 45 deletions

1
changelog.d/2930.misc Normal file
View file

@ -0,0 +1 @@
Add a feature flag ShowBlockedUsersDetails, disabled by default to render display name and avatar of blocked users in the blocked users list.

View file

@ -21,20 +21,26 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class BlockedUsersPresenter @Inject constructor( class BlockedUsersPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService,
) : Presenter<BlockedUsersState> { ) : Presenter<BlockedUsersState> {
@Composable @Composable
override fun present(): BlockedUsersState { override fun present(): BlockedUsersState {
@ -47,7 +53,24 @@ class BlockedUsersPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val renderBlockedUsersDetail = featureFlagService
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
.collectAsState(initial = false)
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState() val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
val ignoredMatrixUser by produceState(
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
key1 = renderBlockedUsersDetail.value,
key2 = ignoredUserIds
) {
value = ignoredUserIds.map {
if (renderBlockedUsersDetail.value) {
matrixClient.getProfile(it).getOrNull()
} else {
null
}
?: MatrixUser(userId = it)
}
}
fun handleEvents(event: BlockedUsersEvents) { fun handleEvents(event: BlockedUsersEvents) {
when (event) { when (event) {
@ -68,7 +91,7 @@ class BlockedUsersPresenter @Inject constructor(
} }
} }
return BlockedUsersState( return BlockedUsersState(
blockedUsers = ignoredUserIds, blockedUsers = ignoredMatrixUser.toPersistentList(),
unblockUserAction = unblockUserAction.value, unblockUserAction = unblockUserAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )

View file

@ -17,11 +17,11 @@
package io.element.android.features.preferences.impl.blockedusers package io.element.android.features.preferences.impl.blockedusers
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class BlockedUsersState( data class BlockedUsersState(
val blockedUsers: ImmutableList<UserId>, val blockedUsers: ImmutableList<MatrixUser>,
val unblockUserAction: AsyncAction<Unit>, val unblockUserAction: AsyncAction<Unit>,
val eventSink: (BlockedUsersEvents) -> Unit, val eventSink: (BlockedUsersEvents) -> Unit,
) )

View file

@ -18,7 +18,7 @@ package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -26,10 +26,9 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider<BlockedUsersSt
override val values: Sequence<BlockedUsersState> override val values: Sequence<BlockedUsersState>
get() = sequenceOf( get() = sequenceOf(
aBlockedUsersState(), aBlockedUsersState(),
aBlockedUsersState(blockedUsers = aMatrixUserList().map { it.copy(displayName = null, avatarUrl = null) }),
aBlockedUsersState(blockedUsers = emptyList()), aBlockedUsersState(blockedUsers = emptyList()),
aBlockedUsersState(unblockUserAction = AsyncAction.Confirming), aBlockedUsersState(unblockUserAction = AsyncAction.Confirming),
// Sadly there's no good way to preview Loading or Failure states since they're presented with an animation
// All these 3 screen states will be displayed as the Uninitialized one
aBlockedUsersState(unblockUserAction = AsyncAction.Loading), aBlockedUsersState(unblockUserAction = AsyncAction.Loading),
aBlockedUsersState(unblockUserAction = AsyncAction.Failure(Throwable("Failed to unblock user"))), aBlockedUsersState(unblockUserAction = AsyncAction.Failure(Throwable("Failed to unblock user"))),
aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)), aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)),
@ -37,12 +36,13 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider<BlockedUsersSt
} }
internal fun aBlockedUsersState( internal fun aBlockedUsersState(
blockedUsers: List<UserId> = aMatrixUserList().map { it.userId }, blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized, unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState { ): BlockedUsersState {
return BlockedUsersState( return BlockedUsersState(
blockedUsers = blockedUsers.toPersistentList(), blockedUsers = blockedUsers.toPersistentList(),
unblockUserAction = unblockUserAction, unblockUserAction = unblockUserAction,
eventSink = {}, eventSink = eventSink,
) )
} }

View file

@ -73,9 +73,9 @@ fun BlockedUsersView(
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) { ) {
items(state.blockedUsers) { userId -> items(state.blockedUsers) { matrixUser ->
BlockedUserItem( BlockedUserItem(
userId = userId, matrixUser = matrixUser,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) }
) )
} }
@ -121,12 +121,12 @@ fun BlockedUsersView(
@Composable @Composable
private fun BlockedUserItem( private fun BlockedUserItem(
userId: UserId, matrixUser: MatrixUser,
onClick: (UserId) -> Unit, onClick: (UserId) -> Unit,
) { ) {
MatrixUserRow( MatrixUserRow(
modifier = Modifier.clickable { onClick(userId) }, modifier = Modifier.clickable { onClick(matrixUser.userId) },
matrixUser = MatrixUser(userId), matrixUser = matrixUser,
) )
} }

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BlockedUserViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes back callback`() {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setLogoutView(
aBlockedUsersState(
eventSink = eventsRecorder
),
onBackClicked = callback,
)
rule.pressBack()
}
}
@Test
fun `clicking on a user emits the expected Event`() {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
val userList = aMatrixUserList()
rule.setLogoutView(
aBlockedUsersState(
blockedUsers = userList,
eventSink = eventsRecorder
),
)
rule.onNodeWithText(userList.first().displayName.orEmpty()).performClick()
eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId))
}
@Test
fun `clicking on cancel sends a BlockedUsersEvents`() {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
rule.setLogoutView(
aBlockedUsersState(
unblockUserAction = AsyncAction.Confirming,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(BlockedUsersEvents.Cancel)
}
@Test
fun `clicking on confirm sends a BlockedUsersEvents`() {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
rule.setLogoutView(
aBlockedUsersState(
unblockUserAction = AsyncAction.Confirming,
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_blocked_users_unblock_alert_action)
eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLogoutView(
state: BlockedUsersState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
BlockedUsersView(
state = state,
onBackPressed = onBackClicked,
)
}
}

View file

@ -21,6 +21,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
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.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -52,7 +57,7 @@ class BlockedUsersPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID)) assertThat(blockedUsers).isEqualTo(persistentListOf(MatrixUser(A_USER_ID)))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
} }
} }
@ -68,14 +73,39 @@ class BlockedUsersPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(blockedUsers).containsAtLeastElementsIn(persistentListOf(A_USER_ID)) assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID)))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
} }
matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2)
skipItems(1)
with(awaitItem()) { with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID, A_USER_ID_2)) assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2)))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) }
}
}
@Test
fun `present - blocked users list with data`() = runTest {
val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar")
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2)
givenGetProfileResult(A_USER_ID, Result.success(alice))
givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION))
}
val presenter = aBlockedUsersPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.ShowBlockedUsersDetails, true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2)))
}
// Alice is resolved
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(listOf(alice, MatrixUser(A_USER_ID_2)))
} }
} }
} }
@ -157,5 +187,9 @@ class BlockedUsersPresenterTests {
private fun aBlockedUsersPresenter( private fun aBlockedUsersPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
) = BlockedUsersPresenter(matrixClient) featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = BlockedUsersPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
)
} }

View file

@ -81,5 +81,12 @@ enum class FeatureFlags(
description = "Allow user to search for public rooms in their homeserver", description = "Allow user to search for public rooms in their homeserver",
defaultValue = false, defaultValue = false,
isFinished = false, isFinished = false,
) ),
ShowBlockedUsersDetails(
key = "feature.showBlockedUsersDetails",
title = "Show blocked users details",
description = "Show the name and avatar of blocked users in the blocked users list",
defaultValue = false,
isFinished = false,
),
} }

View file

@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.Mentions -> true FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> true FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomDirectorySearch -> false FeatureFlags.RoomDirectorySearch -> false
FeatureFlags.ShowBlockedUsersDetails -> false
} }
} else { } else {
false false

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40 oid sha256:0268cabfadfeda384616b4d93895d170efa8360c81de2f729111dfc1c4c76da0
size 62939 size 62874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8c9e8374a634bf28b84e9945596a0c144971f550f8c1cd57abc0f4db30556fe5 oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40
size 8959 size 62939

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:709a470ac8764fee25eca83978daf039c089f2e8073b22afc1aa08fcf0d69998 oid sha256:8c9e8374a634bf28b84e9945596a0c144971f550f8c1cd57abc0f4db30556fe5
size 60072 size 8959

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a515efeb80f1380d661f8531f0c85bb46abd71bf535e050444fbb9f3e72f7011 oid sha256:1a36e3680e98e899f23c3c208f8f51ed6195b99ee0ccd5f7fe4b67992ca1af8e
size 65964 size 60700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9a47cd564da066d38db3e26f7b80b2a6e9a63ab276a2af688bee10bae462e775 oid sha256:f69003da2a20cc3aa6c5a9695fc5dbece0cfa266b0c0c784249a40eecf854925
size 65580 size 65905

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40 oid sha256:92019951732c798875716694ac2495a72135413501d1fc28e1162efa1a717da0
size 62939 size 65524

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0268cabfadfeda384616b4d93895d170efa8360c81de2f729111dfc1c4c76da0
size 62874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144 oid sha256:ed2277c886df7537766880554569b94e9587821253f0d62a0f25c760cfecb427
size 58891 size 62315

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cc12e1a6ebd2340718bfb70a5ceb265c526cef8c1fac285e1e6ec924a7c12324 oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144
size 8393 size 58891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fc5c62caa2da7e51d035ffc7a00d9d3abaeee06cbd48c3d0a6e7e3226c04e4e4 oid sha256:cc12e1a6ebd2340718bfb70a5ceb265c526cef8c1fac285e1e6ec924a7c12324
size 55857 size 8393

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6974b5d06b3d8b64bf26c3b0b48cd57a2c2e38cee6aa8b06bb85d64392e1b685 oid sha256:6c65880e75d0d796a3f122b4896b7134cd93448e3b26e38c7f5b9a920b1eaceb
size 60855 size 56704

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:96053ba2bb1dbbb69cd0ddb7e218ad68401ee1344844508766c5724a08e171fb oid sha256:1b42ea9c681420e13912166b8d42195931c93e7f0fc69bea711ed2027395c44c
size 60491 size 64232

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144 oid sha256:e72323d61798f93d768e06e86e7e03335297e93fc82bd96083d1a27bfa5a9b78
size 58891 size 63874

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed2277c886df7537766880554569b94e9587821253f0d62a0f25c760cfecb427
size 62315