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 b062ea18bc..70fa8e0e67 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 CopyID(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 ee63be7643..52e8a2f415 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 @@ -22,9 +22,13 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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 @@ -41,6 +45,7 @@ import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin +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 @@ -60,6 +65,8 @@ class RoomDetailsPresenter @Inject constructor( private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, + private val clipboardHelper: ClipboardHelper, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @Composable override fun present(): RoomDetailsState { @@ -110,6 +117,7 @@ class RoomDetailsPresenter @Inject constructor( } } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() fun handleEvents(event: RoomDetailsEvent) { @@ -126,6 +134,12 @@ class RoomDetailsPresenter @Inject constructor( client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne) } } + is RoomDetailsEvent.CopyID -> { + scope.launch(dispatchers.io) { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) } } @@ -154,6 +168,7 @@ class RoomDetailsPresenter @Inject constructor( heroes = roomInfo?.heroes.orEmpty().toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, pinnedMessagesCount = pinnedMessagesCount, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents, ) } 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 f33b647a13..4d11ced0c9 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 @@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl import androidx.compose.runtime.Immutable import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.userprofile.shared.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 @@ -39,6 +40,7 @@ data class RoomDetailsState( val heroes: ImmutableList, val canShowPinnedMessages: Boolean, val pinnedMessagesCount: Int?, + val snackbarMessage: SnackbarMessage?, val eventSink: (RoomDetailsEvent) -> Unit ) 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 3746407fbf..e63c65a0e9 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 @@ -13,6 +13,7 @@ import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.shared.aUserProfileState +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 @@ -99,6 +100,7 @@ fun aRoomDetailsState( heroes: List = emptyList(), canShowPinnedMessages: Boolean = true, pinnedMessagesCount: Int? = null, + snackbarMessage: SnackbarMessage? = null, eventSink: (RoomDetailsEvent) -> Unit = {}, ) = RoomDetailsState( roomId = roomId, @@ -122,7 +124,8 @@ fun aRoomDetailsState( heroes = heroes.toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, pinnedMessagesCount = pinnedMessagesCount, - eventSink = eventSink + snackbarMessage = snackbarMessage, + 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 b3b017f0e3..8a69406acd 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -33,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter @@ -71,6 +73,8 @@ 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.CommonDrawables +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.room.RoomMember @@ -102,6 +106,8 @@ fun RoomDetailsView( onPinnedMessagesClick: () -> Unit, modifier: Modifier = Modifier, ) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold( modifier = modifier, topBar = { @@ -111,6 +117,7 @@ fun RoomDetailsView( onActionClick = onActionClick ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column( modifier = Modifier @@ -131,6 +138,9 @@ fun RoomDetailsView( openAvatarPreview = { avatarUrl -> openAvatarPreview(state.roomName, avatarUrl) }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyID(subtitle)) + }, ) } is RoomDetailsType.Dm -> { @@ -141,6 +151,9 @@ fun RoomDetailsView( openAvatarPreview = { name, avatarUrl -> openAvatarPreview(name, avatarUrl) }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyID(subtitle)) + }, ) } } @@ -330,6 +343,7 @@ private fun RoomHeaderSection( roomAlias: RoomAlias?, heroes: ImmutableList, openAvatarPreview: (url: String) -> Unit, + onSubtitleClick: (String) -> Unit, ) { Column( modifier = Modifier @@ -346,7 +360,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, + ) } } @@ -356,6 +374,7 @@ private fun DmHeaderSection( otherMember: RoomMember, roomName: String, openAvatarPreview: (name: String, url: String) -> Unit, + onSubtitleClick: (String) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -373,6 +392,7 @@ private fun DmHeaderSection( TitleAndSubtitle( title = roomName, subtitle = otherMember.userId.value, + onSubtitleClick = onSubtitleClick, ) } } @@ -381,6 +401,7 @@ private fun DmHeaderSection( private fun ColumnScope.TitleAndSubtitle( title: String, subtitle: String?, + onSubtitleClick: (String) -> Unit, ) { Spacer(modifier = Modifier.height(24.dp)) Text( @@ -391,6 +412,10 @@ private fun ColumnScope.TitleAndSubtitle( if (subtitle != null) { Spacer(modifier = Modifier.height(6.dp)) Text( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { onSubtitleClick(subtitle) } + .padding(horizontal = 4.dp), text = subtitle, style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary, 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 cf59f0db1c..ab286c2e8c 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,9 @@ import dagger.Module import dagger.Provides import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId @@ -25,10 +28,13 @@ object RoomMemberModule { matrixClient: MatrixClient, room: MatrixRoom, startDMAction: StartDMAction, + dispatchers: CoroutineDispatchers, + clipboardHelper: ClipboardHelper, + snackbarDispatcher: SnackbarDispatcher, ): RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { - return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction) + return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction, dispatchers, clipboardHelper, snackbarDispatcher) } } } 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 768c717e73..451e830850 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 @@ -23,16 +23,22 @@ import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.shared.UserProfilePresenterHelper import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -44,6 +50,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( private val client: MatrixClient, private val room: MatrixRoom, private val startDMAction: StartDMAction, + private val dispatchers: CoroutineDispatchers, + private val clipboardHelper: ClipboardHelper, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { interface Factory { fun create(roomMemberId: UserId): RoomMemberDetailsPresenter @@ -65,6 +74,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( val isCurrentUser = remember { client.isMe(roomMemberId) } val dmRoomId by userProfilePresenterHelper.getDmRoomId() val canCall by userProfilePresenterHelper.getCanCall(dmRoomId) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() LaunchedEffect(Unit) { client.ignoredUsersFlow .map { ignoredUsers -> roomMemberId in ignoredUsers } @@ -112,6 +122,12 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( UserProfileEvents.ClearStartDMState -> { startDmActionState.value = AsyncAction.Uninitialized } + is UserProfileEvents.CopyID -> { + coroutineScope.launch(dispatchers.io) { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } } } @@ -155,7 +171,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( isCurrentUser = isCurrentUser, dmRoomId = dmRoomId, canCall = canCall, - eventSink = ::handleEvents + snackbarMessage = snackbarMessage, + eventSink = ::handleEvents, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt index 84f6f5f341..2f593c1fee 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt @@ -25,7 +25,9 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.UserId @@ -77,11 +79,13 @@ class RoomDetailsPresenterTest { notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), analyticsService: AnalyticsService = FakeAnalyticsService(), isPinnedMessagesFeatureEnabled: Boolean = true, + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ): RoomDetailsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { - return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction()) + return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction(), dispatchers, clipboardHelper, snackbarDispatcher) } } val featureFlagService = FakeFeatureFlagService( @@ -97,6 +101,8 @@ class RoomDetailsPresenterTest { dispatchers = dispatchers, isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled }, analyticsService = analyticsService, + clipboardHelper = clipboardHelper, + snackbarDispatcher = snackbarDispatcher, ) } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt index c987e308ab..27a331d485 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt @@ -19,8 +19,10 @@ 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.UserProfileEvents import io.element.android.features.userprofile.shared.UserProfileState +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -31,8 +33,10 @@ import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -332,17 +336,22 @@ class RoomMemberDetailsPresenterTest { return awaitItem() } - private fun createRoomMemberDetailsPresenter( + private fun TestScope.createRoomMemberDetailsPresenter( room: MatrixRoom, client: MatrixClient = FakeMatrixClient(), roomMemberId: UserId = UserId("@alice:server.org"), - startDMAction: StartDMAction = FakeStartDMAction() + startDMAction: StartDMAction = FakeStartDMAction(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ): RoomMemberDetailsPresenter { return RoomMemberDetailsPresenter( roomMemberId = roomMemberId, client = client, room = room, - startDMAction = startDMAction + startDMAction = startDMAction, + dispatchers = testCoroutineDispatchers(), + clipboardHelper = clipboardHelper, + snackbarDispatcher = snackbarDispatcher, ) } } diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt index ae30b10175..b7dd0ad08c 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt @@ -12,6 +12,9 @@ import dagger.Module import dagger.Provides import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.userprofile.impl.root.UserProfilePresenter +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId @@ -23,10 +26,13 @@ object UserProfileModule { fun provideUserProfilePresenterFactory( matrixClient: MatrixClient, startDMAction: StartDMAction, + dispatchers: CoroutineDispatchers, + clipboardHelper: ClipboardHelper, + snackbarDispatcher: SnackbarDispatcher, ): UserProfilePresenter.Factory { return object : UserProfilePresenter.Factory { override fun create(userId: UserId): UserProfilePresenter { - return UserProfilePresenter(userId, matrixClient, startDMAction) + return UserProfilePresenter(userId, matrixClient, startDMAction, dispatchers, clipboardHelper, snackbarDispatcher) } } } 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 136ffcb206..f976ca29f4 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 @@ -22,14 +22,20 @@ import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.shared.UserProfilePresenterHelper import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId 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.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -40,6 +46,9 @@ class UserProfilePresenter @AssistedInject constructor( @Assisted private val userId: UserId, private val client: MatrixClient, private val startDMAction: StartDMAction, + private val dispatchers: CoroutineDispatchers, + private val clipboardHelper: ClipboardHelper, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { interface Factory { fun create(userId: UserId): UserProfilePresenter @@ -59,6 +68,7 @@ class UserProfilePresenter @AssistedInject constructor( val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } val dmRoomId by userProfilePresenterHelper.getDmRoomId() val canCall by userProfilePresenterHelper.getCanCall(dmRoomId) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() LaunchedEffect(Unit) { client.ignoredUsersFlow .map { ignoredUsers -> userId in ignoredUsers } @@ -100,6 +110,12 @@ class UserProfilePresenter @AssistedInject constructor( UserProfileEvents.ClearStartDMState -> { startDmActionState.value = AsyncAction.Uninitialized } + is UserProfileEvents.CopyID -> { + coroutineScope.launch(dispatchers.io) { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } } } @@ -113,7 +129,8 @@ class UserProfilePresenter @AssistedInject constructor( isCurrentUser = client.isMe(userId), dmRoomId = dmRoomId, canCall = canCall, - eventSink = ::handleEvents + snackbarMessage = snackbarMessage, + eventSink = ::handleEvents, ) } } diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 043ea267d7..8ea0b54364 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -17,8 +17,11 @@ import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.userprofile.impl.root.UserProfilePresenter import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.shared.UserProfileState +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -28,7 +31,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -217,15 +222,21 @@ class UserProfilePresenterTest { return awaitItem() } - private fun createUserProfilePresenter( + private fun TestScope.createUserProfilePresenter( client: MatrixClient = FakeMatrixClient(), userId: UserId = UserId("@alice:server.org"), - startDMAction: StartDMAction = FakeStartDMAction() + startDMAction: StartDMAction = FakeStartDMAction(), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ): UserProfilePresenter { return UserProfilePresenter( userId = userId, client = client, - startDMAction = startDMAction + startDMAction = startDMAction, + dispatchers = dispatchers, + clipboardHelper = clipboardHelper, + snackbarDispatcher = snackbarDispatcher, ) } } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt index da096f6288..697eb45f77 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt @@ -14,4 +14,5 @@ sealed interface UserProfileEvents { data class UnblockUser(val needsConfirmation: Boolean = false) : UserProfileEvents data object ClearBlockUserError : UserProfileEvents data object ClearConfirmationDialog : UserProfileEvents + data class CopyID(val text: String) : UserProfileEvents } 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 00a69e9ca3..46ca2aebdb 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 @@ -13,10 +13,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -37,6 +39,7 @@ fun UserProfileHeaderSection( userId: UserId, userName: String?, openAvatarPreview: (url: String) -> Unit, + onUserIdClick: (String) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -66,8 +69,9 @@ fun UserProfileHeaderSection( style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .clip(RoundedCornerShape(4.dp)) + .clickable { onUserIdClick(userId.value) } + .padding(horizontal = 4.dp), textAlign = TextAlign.Center, ) Spacer(Modifier.height(40.dp)) @@ -82,5 +86,6 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview { userId = UserId("@alice:example.com"), userName = "Alice", openAvatarPreview = {}, + onUserIdClick = {}, ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt index ceb3cd7952..e4bce4bb5e 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt @@ -9,6 +9,7 @@ package io.element.android.features.userprofile.shared 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 @@ -22,6 +23,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/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 9126ae49ad..8b5b404bbd 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 @@ -10,6 +10,7 @@ package io.element.android.features.userprofile.shared import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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 @@ -39,6 +40,7 @@ fun aUserProfileState( isCurrentUser: Boolean = false, dmRoomId: RoomId? = null, canCall: Boolean = false, + snackbarMessage: SnackbarMessage? = null, eventSink: (UserProfileEvents) -> Unit = {}, ) = UserProfileState( userId = userId, @@ -50,5 +52,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 250ec0c86c..55afdfe6b7 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 @@ -30,6 +30,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.RoomId import io.element.android.libraries.ui.strings.CommonStrings @@ -44,12 +46,15 @@ fun UserProfileView( openAvatarPreview: (username: String, url: String) -> Unit, modifier: Modifier = Modifier, ) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + BackHandler { goBack() } Scaffold( modifier = modifier, topBar = { TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column( modifier = Modifier @@ -64,6 +69,9 @@ fun UserProfileView( openAvatarPreview = { avatarUrl -> openAvatarPreview(state.userName ?: state.userId.value, avatarUrl) }, + onUserIdClick = { userId -> + state.eventSink(UserProfileEvents.CopyID(userId)) + }, ) UserProfileMainActionsSection( diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 11e1ab922a..d0e1b15177 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -130,6 +130,7 @@ "Call in progress (unsupported)" "Call started" "Chat backup" + "Copied to clipboard" "Copyright" "Creating room…" "Left room"