Merge pull request #7372 from hayaksi1/fix/4893-copy-blocked-user-id

Copy a blocked user's Matrix ID by long pressing them
This commit is contained in:
Benoit Marty
2026-08-11 14:04:19 +02:00
committed by GitHub
7 changed files with 80 additions and 4 deletions
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
sealed interface BlockedUsersEvents { sealed interface BlockedUsersEvents {
data class Unblock(val userId: UserId) : BlockedUsersEvents data class Unblock(val userId: UserId) : BlockedUsersEvents
data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents
data object ConfirmUnblock : BlockedUsersEvents data object ConfirmUnblock : BlockedUsersEvents
data object Cancel : BlockedUsersEvents data object Cancel : BlockedUsersEvents
} }
@@ -18,14 +18,19 @@ 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 dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
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.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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 io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -34,6 +39,8 @@ import kotlinx.coroutines.launch
class BlockedUsersPresenter( class BlockedUsersPresenter(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val clipboardHelper: ClipboardHelper,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<BlockedUsersState> { ) : Presenter<BlockedUsersState> {
@Composable @Composable
override fun present(): BlockedUsersState { override fun present(): BlockedUsersState {
@@ -65,12 +72,18 @@ class BlockedUsersPresenter(
} }
} }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
fun handleEvent(event: BlockedUsersEvents) { fun handleEvent(event: BlockedUsersEvents) {
when (event) { when (event) {
is BlockedUsersEvents.Unblock -> { is BlockedUsersEvents.Unblock -> {
pendingUserToUnblock = event.userId pendingUserToUnblock = event.userId
unblockUserAction.value = AsyncAction.ConfirmingNoParams unblockUserAction.value = AsyncAction.ConfirmingNoParams
} }
is BlockedUsersEvents.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.userId.value)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
BlockedUsersEvents.ConfirmUnblock -> { BlockedUsersEvents.ConfirmUnblock -> {
pendingUserToUnblock?.let { pendingUserToUnblock?.let {
coroutineScope.unblockUser(it, unblockUserAction) coroutineScope.unblockUser(it, unblockUserAction)
@@ -86,6 +99,7 @@ class BlockedUsersPresenter(
return BlockedUsersState( return BlockedUsersState(
blockedUsers = ignoredMatrixUser.toImmutableList(), blockedUsers = ignoredMatrixUser.toImmutableList(),
unblockUserAction = unblockUserAction.value, unblockUserAction = unblockUserAction.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -9,11 +9,13 @@
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.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser 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<MatrixUser>, val blockedUsers: ImmutableList<MatrixUser>,
val unblockUserAction: AsyncAction<Unit>, val unblockUserAction: AsyncAction<Unit>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (BlockedUsersEvents) -> Unit, val eventSink: (BlockedUsersEvents) -> Unit,
) )
@@ -10,6 +10,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.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser 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.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -30,11 +31,13 @@ class BlockedUsersStateProvider : PreviewParameterProvider<BlockedUsersState> {
internal fun aBlockedUsersState( internal fun aBlockedUsersState(
blockedUsers: List<MatrixUser> = aMatrixUserList(), blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized, unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
snackbarMessage: SnackbarMessage? = null,
eventSink: (BlockedUsersEvents) -> Unit = {}, eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState { ): BlockedUsersState {
return BlockedUsersState( return BlockedUsersState(
blockedUsers = blockedUsers.toImmutableList(), blockedUsers = blockedUsers.toImmutableList(),
unblockUserAction = unblockUserAction, unblockUserAction = unblockUserAction,
snackbarMessage = snackbarMessage,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -8,7 +8,7 @@
package io.element.android.features.preferences.impl.blockedusers package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
@@ -31,6 +31,8 @@ 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.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
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 io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
@@ -43,6 +45,7 @@ fun BlockedUsersView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) { Box(modifier = modifier) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -52,7 +55,8 @@ fun BlockedUsersView(
BackButton(onClick = onBackClick) BackButton(onClick = onBackClick)
} }
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
@@ -60,7 +64,8 @@ fun BlockedUsersView(
items(state.blockedUsers) { matrixUser -> items(state.blockedUsers) { matrixUser ->
BlockedUserItem( BlockedUserItem(
matrixUser = matrixUser, matrixUser = matrixUser,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) },
onLongClick = { state.eventSink(BlockedUsersEvents.CopyToClipboard(it)) },
) )
} }
} }
@@ -107,9 +112,14 @@ fun BlockedUsersView(
private fun BlockedUserItem( private fun BlockedUserItem(
matrixUser: MatrixUser, matrixUser: MatrixUser,
onClick: (UserId) -> Unit, onClick: (UserId) -> Unit,
onLongClick: (UserId) -> Unit,
) { ) {
MatrixUserRow( MatrixUserRow(
modifier = Modifier.clickable { onClick(matrixUser.userId) }, modifier = Modifier.combinedClickable(
onClick = { onClick(matrixUser.userId) },
onLongClick = { onLongClick(matrixUser.userId) },
onLongClickLabel = stringResource(CommonStrings.action_copy),
),
matrixUser = matrixUser, matrixUser = matrixUser,
) )
} }
@@ -13,8 +13,10 @@ package io.element.android.features.preferences.impl.blockedusers
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.longClick
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
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
@@ -57,6 +59,20 @@ class BlockedUserViewTest : RobolectricTest() {
eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId))
} }
@Test
fun `long clicking on a user emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
val userList = aMatrixUserList()
setBlockedUsersView(
aBlockedUsersState(
blockedUsers = userList,
eventSink = eventsRecorder
),
)
onNodeWithText(userList.first().displayName.orEmpty()).performTouchInput { longClick() }
eventsRecorder.assertSingle(BlockedUsersEvents.CopyToClipboard(userList.first().userId))
}
@Test @Test
fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest { fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>() val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
@@ -12,7 +12,9 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -21,6 +23,7 @@ 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
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -169,6 +172,29 @@ class BlockedUsersPresenterTest {
} }
} }
@Test
fun `present - copy user id to clipboard`() = runTest {
val clipboardHelper = FakeClipboardHelper()
val matrixClient = FakeMatrixClient(
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
)
val presenter = aBlockedUsersPresenter(
matrixClient = matrixClient,
clipboardHelper = clipboardHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.snackbarMessage).isNull()
initialState.eventSink(BlockedUsersEvents.CopyToClipboard(A_USER_ID))
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_USER_ID.value)
assertThat(awaitItem().snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - confirm unblock without a pending blocked user does nothing`() = runTest { fun `present - confirm unblock without a pending blocked user does nothing`() = runTest {
val presenter = aBlockedUsersPresenter() val presenter = aBlockedUsersPresenter()
@@ -183,8 +209,12 @@ class BlockedUsersPresenterTest {
private fun aBlockedUsersPresenter( private fun aBlockedUsersPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
) = BlockedUsersPresenter( ) = BlockedUsersPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
clipboardHelper = clipboardHelper,
snackbarDispatcher = snackbarDispatcher,
) )
} }