From 2f0eb9f068500f8546af87c0b1fe710d53102c08 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 8 Apr 2025 09:23:02 +0200 Subject: [PATCH] Click on userId / room alias to copy value to clipboard. (#4549) --- .../roomdetails/impl/RoomDetailsEvent.kt | 1 + .../roomdetails/impl/RoomDetailsPresenter.kt | 14 ++++++++ .../roomdetails/impl/RoomDetailsState.kt | 2 ++ .../impl/RoomDetailsStateProvider.kt | 5 ++- .../roomdetails/impl/RoomDetailsView.kt | 36 ++++++++++++++----- .../roomdetails/impl/di/RoomMemberModule.kt | 3 ++ .../details/RoomMemberDetailsPresenter.kt | 23 +++++++----- .../impl/RoomDetailsPresenterTest.kt | 5 +++ .../details/RoomMemberDetailsPresenterTest.kt | 4 +++ features/userprofile/api/build.gradle.kts | 1 + .../userprofile/api/UserProfileEvents.kt | 1 + .../userprofile/api/UserProfileState.kt | 2 ++ .../impl/root/UserProfilePresenter.kt | 6 ++-- .../shared/UserProfileHeaderSection.kt | 5 +++ .../shared/UserProfileStateProvider.kt | 3 ++ .../userprofile/shared/UserProfileView.kt | 13 +++++-- .../designsystem/modifiers/Clickable.kt | 12 +++++++ 17 files changed, 114 insertions(+), 22 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index 57d1cc6df0..93c93c345a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent { data object LeaveRoom : RoomDetailsEvent data object MuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent + data class CopyToClipboard(val text: String) : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index bcbdb83e2d..5c6333a290 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +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 @@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.isDmAsState import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.toPersistentList @@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor( private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, + private val clipboardHelper: ClipboardHelper, ) : Presenter { @Composable override fun present(): RoomDetailsState { @@ -134,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor( val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + fun handleEvents(event: RoomDetailsEvent) { when (event) { RoomDetailsEvent.LeaveRoom -> @@ -149,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor( } } is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) + is RoomDetailsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } } @@ -190,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor( canShowPinnedMessages = canShowPinnedMessages, canShowMediaGallery = canShowMediaGallery, pinnedMessagesCount = pinnedMessagesCount, + snackbarMessage = snackbarMessage, canShowKnockRequests = canShowKnockRequests, knockRequestsCount = knockRequestsCount, canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 5502d4e29a..8a0439b15d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember @@ -42,6 +43,7 @@ data class RoomDetailsState( val canShowPinnedMessages: Boolean, val canShowMediaGallery: Boolean, val pinnedMessagesCount: Int?, + val snackbarMessage: SnackbarMessage?, val canShowKnockRequests: Boolean, val knockRequestsCount: Int?, val canShowSecurityAndPrivacy: Boolean, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index b2db46115c..4304f151a3 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -111,6 +112,7 @@ fun aRoomDetailsState( canShowPinnedMessages: Boolean = true, canShowMediaGallery: Boolean = true, pinnedMessagesCount: Int? = null, + snackbarMessage: SnackbarMessage? = null, canShowKnockRequests: Boolean = false, knockRequestsCount: Int? = null, canShowSecurityAndPrivacy: Boolean = true, @@ -139,11 +141,12 @@ fun aRoomDetailsState( canShowPinnedMessages = canShowPinnedMessages, canShowMediaGallery = canShowMediaGallery, pinnedMessagesCount = pinnedMessagesCount, + snackbarMessage = snackbarMessage, canShowKnockRequests = canShowKnockRequests, knockRequestsCount = knockRequestsCount, canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, hasMemberVerificationViolations = hasMemberVerificationViolations, - eventSink = eventSink + eventSink = eventSink, ) fun aRoomNotificationSettings( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 33985c44ee..381f94ad26 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight @@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text 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.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -106,6 +109,7 @@ fun RoomDetailsView( onProfileClick: (UserId) -> Unit, modifier: Modifier = Modifier, ) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Scaffold( modifier = modifier, topBar = { @@ -115,6 +119,7 @@ fun RoomDetailsView( onActionClick = onActionClick ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column( modifier = Modifier @@ -135,6 +140,9 @@ fun RoomDetailsView( openAvatarPreview = { avatarUrl -> openAvatarPreview(state.roomName, avatarUrl) }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle)) + } ) } is RoomDetailsType.Dm -> { @@ -145,6 +153,9 @@ fun RoomDetailsView( openAvatarPreview = { name, avatarUrl -> openAvatarPreview(name, avatarUrl) }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle)) + } ) } } @@ -368,6 +379,7 @@ private fun RoomHeaderSection( roomAlias: RoomAlias?, heroes: ImmutableList, openAvatarPreview: (url: String) -> Unit, + onSubtitleClick: (String) -> Unit, ) { Column( modifier = Modifier @@ -384,7 +396,11 @@ private fun RoomHeaderSection( .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .testTag(TestTags.roomDetailAvatar) ) - TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value) + TitleAndSubtitle( + title = roomName, + subtitle = roomAlias?.value, + onSubtitleClick = onSubtitleClick, + ) } } @@ -394,6 +410,7 @@ private fun DmHeaderSection( otherMember: RoomMember, roomName: String, openAvatarPreview: (name: String, url: String) -> Unit, + onSubtitleClick: (String) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -411,6 +428,7 @@ private fun DmHeaderSection( TitleAndSubtitle( title = roomName, subtitle = otherMember.userId.value, + onSubtitleClick = onSubtitleClick, ) } } @@ -419,6 +437,7 @@ private fun DmHeaderSection( private fun TitleAndSubtitle( title: String, subtitle: String?, + onSubtitleClick: (String) -> Unit, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(24.dp)) @@ -430,6 +449,7 @@ private fun TitleAndSubtitle( if (subtitle != null) { Spacer(modifier = Modifier.height(6.dp)) Text( + modifier = Modifier.niceClickable { onSubtitleClick(subtitle) }, text = subtitle, style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textSecondary, @@ -612,13 +632,13 @@ private fun PinnedMessagesItem( headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), trailingContent = - if (pinnedMessagesCount == null) { - ListItemContent.Custom { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) - } - } else { - ListItemContent.Text(pinnedMessagesCount.toString()) - }, + if (pinnedMessagesCount == null) { + ListItemContent.Custom { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + } + } else { + ListItemContent.Text(pinnedMessagesCount.toString()) + }, onClick = { analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton) onPinnedMessagesClick() diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt index 4001eb7edb..e5f63332aa 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt @@ -12,6 +12,7 @@ import dagger.Module import dagger.Provides import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.userprofile.api.UserProfilePresenterFactory +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -25,6 +26,7 @@ object RoomMemberModule { room: MatrixRoom, userProfilePresenterFactory: UserProfilePresenterFactory, encryptionService: EncryptionService, + clipboardHelper: ClipboardHelper, ): RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { @@ -33,6 +35,7 @@ object RoomMemberModule { room = room, userProfilePresenterFactory = userProfilePresenterFactory, encryptionService = encryptionService, + clipboardHelper = clipboardHelper, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index 9a5d456cbe..c5de24c603 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents import io.element.android.features.userprofile.api.UserProfilePresenterFactory import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.identity.IdentityState @@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull @@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @Assisted private val roomMemberId: UserId, private val room: MatrixRoom, private val encryptionService: EncryptionService, + private val clipboardHelper: ClipboardHelper, userProfilePresenterFactory: UserProfilePresenterFactory, ) : Presenter { interface Factory { @@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( override fun present(): UserProfileState { val coroutineScope = rememberCoroutineScope() + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val roomMember by room.getRoomMemberAsState(roomMemberId) LaunchedEffect(Unit) { // Update room member info when opening this screen @@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( UserProfileEvents.WithdrawVerification -> coroutineScope.launch { encryptionService.withdrawVerification(roomMemberId) } - else -> Unit + is UserProfileEvents.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + else -> userProfileState.eventSink(event) } } @@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( userName = roomUserName ?: userProfileState.userName, avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl, verificationState = verificationState, - eventSink = { event -> - if (event is UserProfileEvents.WithdrawVerification) { - eventSink(UserProfileEvents.WithdrawVerification) - } else { - userProfileState.eventSink(event) - } - } + snackbarMessage = snackbarMessage, + eventSink = ::eventSink ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 07d10ede63..6c88ce8c2b 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -17,6 +17,8 @@ import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -81,6 +83,7 @@ class RoomDetailsPresenterTest { ), isPinnedMessagesFeatureEnabled: Boolean = true, encryptionService: FakeEncryptionService = FakeEncryptionService(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), ): RoomDetailsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { @@ -92,6 +95,7 @@ class RoomDetailsPresenterTest { Presenter { aUserProfileState() } }, encryptionService = encryptionService, + clipboardHelper = clipboardHelper, ) } } @@ -106,6 +110,7 @@ class RoomDetailsPresenterTest { dispatchers = dispatchers, isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled }, analyticsService = analyticsService, + clipboardHelper = clipboardHelper, ) } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt index 1506f50f42..41ff8706ae 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt @@ -17,6 +17,8 @@ import io.element.android.features.userprofile.api.UserProfileEvents import io.element.android.features.userprofile.api.UserProfilePresenterFactory import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState @@ -350,12 +352,14 @@ class RoomMemberDetailsPresenterTest { } }, encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), ): RoomMemberDetailsPresenter { return RoomMemberDetailsPresenter( roomMemberId = UserId("@alice:server.org"), room = room, userProfilePresenterFactory = userProfilePresenterFactory, encryptionService = encryptionService, + clipboardHelper = clipboardHelper, ) } } diff --git a/features/userprofile/api/build.gradle.kts b/features/userprofile/api/build.gradle.kts index b2c1068556..8bdaa8d77e 100644 --- a/features/userprofile/api/build.gradle.kts +++ b/features/userprofile/api/build.gradle.kts @@ -16,5 +16,6 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt index b7b7ba2561..4a5f6bb415 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt @@ -15,4 +15,5 @@ sealed interface UserProfileEvents { data object ClearBlockUserError : UserProfileEvents data object ClearConfirmationDialog : UserProfileEvents data object WithdrawVerification : UserProfileEvents + data class CopyToClipboard(val text: String) : UserProfileEvents } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt index f32033b0a7..b9f08a27f3 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -9,6 +9,7 @@ package io.element.android.features.userprofile.api import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -23,6 +24,7 @@ data class UserProfileState( val isCurrentUser: Boolean, val dmRoomId: RoomId?, val canCall: Boolean, + val snackbarMessage: SnackbarMessage?, val eventSink: (UserProfileEvents) -> Unit ) { enum class ConfirmationDialog { diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index 4216287a70..c098177529 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -120,8 +120,9 @@ class UserProfilePresenter @AssistedInject constructor( UserProfileEvents.ClearStartDMState -> { startDmActionState.value = AsyncAction.Uninitialized } - // Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed - UserProfileEvents.WithdrawVerification -> Unit + // Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed + UserProfileEvents.WithdrawVerification, + is UserProfileEvents.CopyToClipboard -> Unit } } @@ -136,6 +137,7 @@ class UserProfilePresenter @AssistedInject constructor( isCurrentUser = isCurrentUser, dmRoomId = dmRoomId, canCall = canCall, + snackbarMessage = null, eventSink = ::handleEvents ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt index 2681d7bfdf..da88c0f508 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRow import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.ButtonSize @@ -48,6 +49,7 @@ fun UserProfileHeaderSection( userName: String?, verificationState: UserProfileVerificationState, openAvatarPreview: (url: String) -> Unit, + onUserIdClick: () -> Unit, withdrawVerificationClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -75,6 +77,7 @@ fun UserProfileHeaderSection( Spacer(modifier = Modifier.height(6.dp)) } Text( + modifier = Modifier.niceClickable { onUserIdClick() }, text = userId.value, style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textSecondary, @@ -125,6 +128,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview { userName = "Alice", verificationState = UserProfileVerificationState.VERIFIED, openAvatarPreview = {}, + onUserIdClick = {}, withdrawVerificationClick = {}, ) } @@ -138,6 +142,7 @@ internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = Elemen userName = "Alice", verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION, openAvatarPreview = {}, + onUserIdClick = {}, withdrawVerificationClick = {}, ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index be9fad9c94..7a5cc53239 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -14,6 +14,7 @@ import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -45,6 +46,7 @@ fun aUserProfileState( isCurrentUser: Boolean = false, dmRoomId: RoomId? = null, canCall: Boolean = false, + snackbarMessage: SnackbarMessage? = null, eventSink: (UserProfileEvents) -> Unit = {}, ) = UserProfileState( userId = userId, @@ -57,5 +59,6 @@ fun aUserProfileState( isCurrentUser = isCurrentUser, dmRoomId = dmRoomId, canCall = canCall, + snackbarMessage = snackbarMessage, eventSink = eventSink, ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 0c544cf659..a43478e466 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -38,6 +38,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text 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.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet @@ -55,17 +57,19 @@ fun UserProfileView( onVerifyClick: (UserId) -> Unit, modifier: Modifier = Modifier, ) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Scaffold( modifier = modifier, topBar = { TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) - .verticalScroll(rememberScrollState()) + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(rememberScrollState()) ) { UserProfileHeaderSection( avatarUrl = state.avatarUrl, @@ -75,6 +79,9 @@ fun UserProfileView( openAvatarPreview = { avatarUrl -> openAvatarPreview(state.userName ?: state.userId.value, avatarUrl) }, + onUserIdClick = { + state.eventSink(UserProfileEvents.CopyToClipboard(state.userId.value)) + }, withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) }, ) UserProfileMainActionsSection( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt index c8f71f16cc..3931d8d8d0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt @@ -8,7 +8,11 @@ package io.element.android.libraries.designsystem.modifiers import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then( if (onClick != null) { @@ -17,3 +21,11 @@ fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then( Modifier } ) + +fun Modifier.niceClickable( + onClick: () -> Unit, +): Modifier { + return clip(RoundedCornerShape(4.dp)) + .clickable { onClick() } + .padding(horizontal = 4.dp) +}