From d04779c8fd1a673addb016476afc40e655ecfad3 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:09:59 +0300 Subject: [PATCH] Copy a blocked user's Matrix ID by long pressing them The blocked users list displayed each Matrix ID but offered no way to get it out of the app, which is what someone who blocked a user for abuse needs in order to report them onwards. Long press now copies the Matrix ID and confirms with a snackbar, reusing the ClipboardHelper and SnackbarDispatcher idiom from the user profile screen. A short tap still opens the unblock confirmation. The row declares a long click label so the action is announced by a screen reader. No new string is needed. --- .../impl/blockedusers/BlockedUsersEvents.kt | 1 + .../blockedusers/BlockedUsersPresenter.kt | 14 +++++++++ .../impl/blockedusers/BlockedUsersState.kt | 2 ++ .../blockedusers/BlockedUsersStateProvider.kt | 3 ++ .../impl/blockedusers/BlockedUsersView.kt | 18 ++++++++--- .../impl/blockedusers/BlockedUserViewTest.kt | 16 ++++++++++ .../blockedusers/BlockedUsersPresenterTest.kt | 30 +++++++++++++++++++ 7 files changed, 80 insertions(+), 4 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt index 0db5c4c576..31e516e730 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId sealed interface BlockedUsersEvents { data class Unblock(val userId: UserId) : BlockedUsersEvents + data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents data object ConfirmUnblock : BlockedUsersEvents data object Cancel : BlockedUsersEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index fc150d0bfc..2944089fa1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -18,14 +18,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.Presenter 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.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 io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -34,6 +39,8 @@ import kotlinx.coroutines.launch class BlockedUsersPresenter( private val matrixClient: MatrixClient, private val featureFlagService: FeatureFlagService, + private val clipboardHelper: ClipboardHelper, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @Composable override fun present(): BlockedUsersState { @@ -65,12 +72,18 @@ class BlockedUsersPresenter( } } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + fun handleEvent(event: BlockedUsersEvents) { when (event) { is BlockedUsersEvents.Unblock -> { pendingUserToUnblock = event.userId unblockUserAction.value = AsyncAction.ConfirmingNoParams } + is BlockedUsersEvents.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.userId.value) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } BlockedUsersEvents.ConfirmUnblock -> { pendingUserToUnblock?.let { coroutineScope.unblockUser(it, unblockUserAction) @@ -86,6 +99,7 @@ class BlockedUsersPresenter( return BlockedUsersState( blockedUsers = ignoredMatrixUser.toImmutableList(), unblockUserAction = unblockUserAction.value, + snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt index 8c478371ad..e4b07386c7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt @@ -9,11 +9,13 @@ package io.element.android.features.preferences.impl.blockedusers 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 kotlinx.collections.immutable.ImmutableList data class BlockedUsersState( val blockedUsers: ImmutableList, val unblockUserAction: AsyncAction, + val snackbarMessage: SnackbarMessage?, val eventSink: (BlockedUsersEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt index 92cc8a0936..0e0e9e5ca5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt @@ -10,6 +10,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.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import kotlinx.collections.immutable.toImmutableList @@ -30,11 +31,13 @@ class BlockedUsersStateProvider : PreviewParameterProvider { internal fun aBlockedUsersState( blockedUsers: List = aMatrixUserList(), unblockUserAction: AsyncAction = AsyncAction.Uninitialized, + snackbarMessage: SnackbarMessage? = null, eventSink: (BlockedUsersEvents) -> Unit = {}, ): BlockedUsersState { return BlockedUsersState( blockedUsers = blockedUsers.toImmutableList(), unblockUserAction = unblockUserAction, + snackbarMessage = snackbarMessage, eventSink = eventSink, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt index 40e58022bd..c4d8387295 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -8,7 +8,7 @@ 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.padding 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.theme.components.Scaffold 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.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow @@ -43,6 +45,7 @@ fun BlockedUsersView( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Box(modifier = modifier) { Scaffold( topBar = { @@ -52,7 +55,8 @@ fun BlockedUsersView( BackButton(onClick = onBackClick) } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> LazyColumn( modifier = Modifier.padding(padding) @@ -60,7 +64,8 @@ fun BlockedUsersView( items(state.blockedUsers) { matrixUser -> BlockedUserItem( 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( matrixUser: MatrixUser, onClick: (UserId) -> Unit, + onLongClick: (UserId) -> Unit, ) { 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, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt index 467ace4675..58e0cf467f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -13,8 +13,10 @@ package io.element.android.features.preferences.impl.blockedusers import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.architecture.AsyncAction @@ -57,6 +59,20 @@ class BlockedUserViewTest : RobolectricTest() { eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) } + @Test + fun `long clicking on a user emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val userList = aMatrixUserList() + setBlockedUsersView( + aBlockedUsersState( + blockedUsers = userList, + eventSink = eventsRecorder + ), + ) + onNodeWithText(userList.first().displayName.orEmpty()).performTouchInput { longClick() } + eventsRecorder.assertSingle(BlockedUsersEvents.CopyToClipboard(userList.first().userId)) + } + @Test fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt index d3ac29df9a..4e0164b2a0 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt @@ -12,7 +12,9 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test 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.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags 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_2 import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow 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 fun `present - confirm unblock without a pending blocked user does nothing`() = runTest { val presenter = aBlockedUsersPresenter() @@ -183,8 +209,12 @@ class BlockedUsersPresenterTest { private fun aBlockedUsersPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ) = BlockedUsersPresenter( matrixClient = matrixClient, featureFlagService = featureFlagService, + clipboardHelper = clipboardHelper, + snackbarDispatcher = snackbarDispatcher, ) }