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.
This commit is contained in:
hayaksi1
2026-08-09 15:09:59 +03:00
parent 044737a3a7
commit d04779c8fd
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 {
data class Unblock(val userId: UserId) : BlockedUsersEvents
data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents
data object ConfirmUnblock : BlockedUsersEvents
data object Cancel : BlockedUsersEvents
}
@@ -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<BlockedUsersState> {
@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,
)
}
@@ -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<MatrixUser>,
val unblockUserAction: AsyncAction<Unit>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (BlockedUsersEvents) -> Unit,
)
@@ -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<BlockedUsersState> {
internal fun aBlockedUsersState(
blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
snackbarMessage: SnackbarMessage? = null,
eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState {
return BlockedUsersState(
blockedUsers = blockedUsers.toImmutableList(),
unblockUserAction = unblockUserAction,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
}
@@ -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,
)
}
@@ -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<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
fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
@@ -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,
)
}