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 f74032d87a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class BlockedUsersPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService,
) : Presenter<BlockedUsersState> {
@Composable
override fun present(): BlockedUsersState {
@ -47,7 +53,24 @@ class BlockedUsersPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized)
}
val renderBlockedUsersDetail = featureFlagService
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
.collectAsState(initial = false)
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) {
when (event) {
@ -68,7 +91,7 @@ class BlockedUsersPresenter @Inject constructor(
}
}
return BlockedUsersState(
blockedUsers = ignoredUserIds,
blockedUsers = ignoredMatrixUser.toPersistentList(),
unblockUserAction = unblockUserAction.value,
eventSink = ::handleEvents
)

View file

@ -17,11 +17,11 @@
package io.element.android.features.preferences.impl.blockedusers
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
data class BlockedUsersState(
val blockedUsers: ImmutableList<UserId>,
val blockedUsers: ImmutableList<MatrixUser>,
val unblockUserAction: AsyncAction<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 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 kotlinx.collections.immutable.toPersistentList
@ -26,10 +26,9 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider<BlockedUsersSt
override val values: Sequence<BlockedUsersState>
get() = sequenceOf(
aBlockedUsersState(),
aBlockedUsersState(blockedUsers = aMatrixUserList().map { it.copy(displayName = null, avatarUrl = null) }),
aBlockedUsersState(blockedUsers = emptyList()),
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.Failure(Throwable("Failed to unblock user"))),
aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)),
@ -37,12 +36,13 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider<BlockedUsersSt
}
internal fun aBlockedUsersState(
blockedUsers: List<UserId> = aMatrixUserList().map { it.userId },
blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState {
return BlockedUsersState(
blockedUsers = blockedUsers.toPersistentList(),
unblockUserAction = unblockUserAction,
eventSink = {},
eventSink = eventSink,
)
}

View file

@ -73,9 +73,9 @@ fun BlockedUsersView(
LazyColumn(
modifier = Modifier.padding(padding)
) {
items(state.blockedUsers) { userId ->
items(state.blockedUsers) { matrixUser ->
BlockedUserItem(
userId = userId,
matrixUser = matrixUser,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) }
)
}
@ -121,12 +121,12 @@ fun BlockedUsersView(
@Composable
private fun BlockedUserItem(
userId: UserId,
matrixUser: MatrixUser,
onClick: (UserId) -> Unit,
) {
MatrixUserRow(
modifier = Modifier.clickable { onClick(userId) },
matrixUser = MatrixUser(userId),
modifier = Modifier.clickable { onClick(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 com.google.common.truth.Truth.assertThat
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_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -52,7 +57,7 @@ class BlockedUsersPresenterTests {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID))
assertThat(blockedUsers).isEqualTo(persistentListOf(MatrixUser(A_USER_ID)))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@ -68,14 +73,39 @@ class BlockedUsersPresenterTests {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).containsAtLeastElementsIn(persistentListOf(A_USER_ID))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID)))
}
matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2)
skipItems(1)
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID, A_USER_ID_2))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2)))
}
}
}
@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(
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",
defaultValue = 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.MarkAsUnread -> true
FeatureFlags.RoomDirectorySearch -> false
FeatureFlags.ShowBlockedUsersDetails -> false
}
} else {
false

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40
size 62939
oid sha256:92019951732c798875716694ac2495a72135413501d1fc28e1162efa1a717da0
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
oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144
size 58891
oid sha256:ed2277c886df7537766880554569b94e9587821253f0d62a0f25c760cfecb427
size 62315

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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