Skip to content

Commit c88465c

Browse files
committed
Copy user-id/room-alias to clipboard on click
Make user id and room alias text in room/user view pages clickable and copy the text to the clipboard on click. Fixes #3496 Signed-off-by: Joe Groocock <[email protected]>
1 parent 249104b commit c88465c

File tree

18 files changed

+153
-15
lines changed

18 files changed

+153
-15
lines changed

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt

+1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
1111
data object LeaveRoom : RoomDetailsEvent
1212
data object MuteNotification : RoomDetailsEvent
1313
data object UnmuteNotification : RoomDetailsEvent
14+
data class CopyID(val text: String) : RoomDetailsEvent
1415
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
1516
}

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt

+15
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
2222
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
2323
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
2424
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
25+
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
2526
import io.element.android.libraries.architecture.Presenter
2627
import io.element.android.libraries.core.bool.orFalse
2728
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
29+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
30+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
31+
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
2832
import io.element.android.libraries.featureflag.api.FeatureFlagService
2933
import io.element.android.libraries.featureflag.api.FeatureFlags
3034
import io.element.android.libraries.matrix.api.MatrixClient
@@ -41,6 +45,7 @@ import io.element.android.libraries.matrix.ui.room.canCall
4145
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
4246
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
4347
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
48+
import io.element.android.libraries.ui.strings.CommonStrings
4449
import io.element.android.services.analytics.api.AnalyticsService
4550
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
4651
import kotlinx.collections.immutable.toPersistentList
@@ -60,6 +65,8 @@ class RoomDetailsPresenter @Inject constructor(
6065
private val dispatchers: CoroutineDispatchers,
6166
private val analyticsService: AnalyticsService,
6267
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
68+
private val clipboardHelper: ClipboardHelper,
69+
private val snackbarDispatcher: SnackbarDispatcher,
6370
) : Presenter<RoomDetailsState> {
6471
@Composable
6572
override fun present(): RoomDetailsState {
@@ -110,6 +117,7 @@ class RoomDetailsPresenter @Inject constructor(
110117
}
111118
}
112119

120+
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
113121
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
114122

115123
fun handleEvents(event: RoomDetailsEvent) {
@@ -126,6 +134,12 @@ class RoomDetailsPresenter @Inject constructor(
126134
client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne)
127135
}
128136
}
137+
is RoomDetailsEvent.CopyID -> {
138+
scope.launch(dispatchers.io) {
139+
clipboardHelper.copyPlainText(event.text)
140+
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
141+
}
142+
}
129143
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
130144
}
131145
}
@@ -154,6 +168,7 @@ class RoomDetailsPresenter @Inject constructor(
154168
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
155169
canShowPinnedMessages = canShowPinnedMessages,
156170
pinnedMessagesCount = pinnedMessagesCount,
171+
snackbarMessage = snackbarMessage,
157172
eventSink = ::handleEvents,
158173
)
159174
}

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl
1010
import androidx.compose.runtime.Immutable
1111
import io.element.android.features.leaveroom.api.LeaveRoomState
1212
import io.element.android.features.userprofile.shared.UserProfileState
13+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
1314
import io.element.android.libraries.matrix.api.core.RoomAlias
1415
import io.element.android.libraries.matrix.api.core.RoomId
1516
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -39,6 +40,7 @@ data class RoomDetailsState(
3940
val heroes: ImmutableList<MatrixUser>,
4041
val canShowPinnedMessages: Boolean,
4142
val pinnedMessagesCount: Int?,
43+
val snackbarMessage: SnackbarMessage?,
4244
val eventSink: (RoomDetailsEvent) -> Unit
4345
)
4446

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.element.android.features.leaveroom.api.aLeaveRoomState
1313
import io.element.android.features.roomdetails.impl.members.aRoomMember
1414
import io.element.android.features.userprofile.shared.UserProfileState
1515
import io.element.android.features.userprofile.shared.aUserProfileState
16+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
1617
import io.element.android.libraries.matrix.api.core.RoomAlias
1718
import io.element.android.libraries.matrix.api.core.RoomId
1819
import io.element.android.libraries.matrix.api.core.UserId
@@ -99,6 +100,7 @@ fun aRoomDetailsState(
99100
heroes: List<MatrixUser> = emptyList(),
100101
canShowPinnedMessages: Boolean = true,
101102
pinnedMessagesCount: Int? = null,
103+
snackbarMessage: SnackbarMessage? = null,
102104
eventSink: (RoomDetailsEvent) -> Unit = {},
103105
) = RoomDetailsState(
104106
roomId = roomId,
@@ -122,7 +124,8 @@ fun aRoomDetailsState(
122124
heroes = heroes.toPersistentList(),
123125
canShowPinnedMessages = canShowPinnedMessages,
124126
pinnedMessagesCount = pinnedMessagesCount,
125-
eventSink = eventSink
127+
snackbarMessage = snackbarMessage,
128+
eventSink = eventSink,
126129
)
127130

128131
fun aRoomNotificationSettings(

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt

+26-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height
2020
import androidx.compose.foundation.layout.padding
2121
import androidx.compose.foundation.layout.size
2222
import androidx.compose.foundation.rememberScrollState
23+
import androidx.compose.foundation.shape.RoundedCornerShape
2324
import androidx.compose.foundation.verticalScroll
2425
import androidx.compose.material.icons.Icons
2526
import androidx.compose.material.icons.filled.MoreVert
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
3334
import androidx.compose.runtime.setValue
3435
import androidx.compose.ui.Alignment
3536
import androidx.compose.ui.Modifier
37+
import androidx.compose.ui.draw.clip
3638
import androidx.compose.ui.res.stringResource
3739
import androidx.compose.ui.text.style.TextAlign
3840
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -71,6 +73,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
7173
import io.element.android.libraries.designsystem.theme.components.Text
7274
import io.element.android.libraries.designsystem.theme.components.TopAppBar
7375
import io.element.android.libraries.designsystem.utils.CommonDrawables
76+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
77+
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
7478
import io.element.android.libraries.matrix.api.core.RoomAlias
7579
import io.element.android.libraries.matrix.api.core.RoomId
7680
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -102,6 +106,8 @@ fun RoomDetailsView(
102106
onPinnedMessagesClick: () -> Unit,
103107
modifier: Modifier = Modifier,
104108
) {
109+
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
110+
105111
Scaffold(
106112
modifier = modifier,
107113
topBar = {
@@ -111,6 +117,7 @@ fun RoomDetailsView(
111117
onActionClick = onActionClick
112118
)
113119
},
120+
snackbarHost = { SnackbarHost(snackbarHostState) },
114121
) { padding ->
115122
Column(
116123
modifier = Modifier
@@ -131,6 +138,9 @@ fun RoomDetailsView(
131138
openAvatarPreview = { avatarUrl ->
132139
openAvatarPreview(state.roomName, avatarUrl)
133140
},
141+
onSubtitleClick = { subtitle ->
142+
state.eventSink(RoomDetailsEvent.CopyID(subtitle))
143+
},
134144
)
135145
}
136146
is RoomDetailsType.Dm -> {
@@ -141,6 +151,9 @@ fun RoomDetailsView(
141151
openAvatarPreview = { name, avatarUrl ->
142152
openAvatarPreview(name, avatarUrl)
143153
},
154+
onSubtitleClick = { subtitle ->
155+
state.eventSink(RoomDetailsEvent.CopyID(subtitle))
156+
},
144157
)
145158
}
146159
}
@@ -330,6 +343,7 @@ private fun RoomHeaderSection(
330343
roomAlias: RoomAlias?,
331344
heroes: ImmutableList<MatrixUser>,
332345
openAvatarPreview: (url: String) -> Unit,
346+
onSubtitleClick: (String) -> Unit,
333347
) {
334348
Column(
335349
modifier = Modifier
@@ -346,7 +360,11 @@ private fun RoomHeaderSection(
346360
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
347361
.testTag(TestTags.roomDetailAvatar)
348362
)
349-
TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
363+
TitleAndSubtitle(
364+
title = roomName,
365+
subtitle = roomAlias?.value,
366+
onSubtitleClick = onSubtitleClick,
367+
)
350368
}
351369
}
352370

@@ -356,6 +374,7 @@ private fun DmHeaderSection(
356374
otherMember: RoomMember,
357375
roomName: String,
358376
openAvatarPreview: (name: String, url: String) -> Unit,
377+
onSubtitleClick: (String) -> Unit,
359378
modifier: Modifier = Modifier
360379
) {
361380
Column(
@@ -373,6 +392,7 @@ private fun DmHeaderSection(
373392
TitleAndSubtitle(
374393
title = roomName,
375394
subtitle = otherMember.userId.value,
395+
onSubtitleClick = onSubtitleClick,
376396
)
377397
}
378398
}
@@ -381,6 +401,7 @@ private fun DmHeaderSection(
381401
private fun ColumnScope.TitleAndSubtitle(
382402
title: String,
383403
subtitle: String?,
404+
onSubtitleClick: (String) -> Unit,
384405
) {
385406
Spacer(modifier = Modifier.height(24.dp))
386407
Text(
@@ -391,6 +412,10 @@ private fun ColumnScope.TitleAndSubtitle(
391412
if (subtitle != null) {
392413
Spacer(modifier = Modifier.height(6.dp))
393414
Text(
415+
modifier = Modifier
416+
.clip(RoundedCornerShape(4.dp))
417+
.clickable { onSubtitleClick(subtitle) }
418+
.padding(horizontal = 4.dp),
394419
text = subtitle,
395420
style = ElementTheme.typography.fontBodyLgRegular,
396421
color = MaterialTheme.colorScheme.secondary,

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import dagger.Module
1212
import dagger.Provides
1313
import io.element.android.features.createroom.api.StartDMAction
1414
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
15+
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
16+
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
17+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
1518
import io.element.android.libraries.di.RoomScope
1619
import io.element.android.libraries.matrix.api.MatrixClient
1720
import io.element.android.libraries.matrix.api.core.UserId
@@ -25,10 +28,13 @@ object RoomMemberModule {
2528
matrixClient: MatrixClient,
2629
room: MatrixRoom,
2730
startDMAction: StartDMAction,
31+
dispatchers: CoroutineDispatchers,
32+
clipboardHelper: ClipboardHelper,
33+
snackbarDispatcher: SnackbarDispatcher,
2834
): RoomMemberDetailsPresenter.Factory {
2935
return object : RoomMemberDetailsPresenter.Factory {
3036
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
31-
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction)
37+
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction, dispatchers, clipboardHelper, snackbarDispatcher)
3238
}
3339
}
3440
}

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt

+18-1
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,22 @@ import io.element.android.features.userprofile.shared.UserProfileEvents
2323
import io.element.android.features.userprofile.shared.UserProfilePresenterHelper
2424
import io.element.android.features.userprofile.shared.UserProfileState
2525
import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog
26+
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
2627
import io.element.android.libraries.architecture.AsyncAction
2728
import io.element.android.libraries.architecture.AsyncData
2829
import io.element.android.libraries.architecture.Presenter
2930
import io.element.android.libraries.core.bool.orFalse
31+
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
32+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
33+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
34+
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
3035
import io.element.android.libraries.matrix.api.MatrixClient
3136
import io.element.android.libraries.matrix.api.core.RoomId
3237
import io.element.android.libraries.matrix.api.core.UserId
3338
import io.element.android.libraries.matrix.api.room.MatrixRoom
3439
import io.element.android.libraries.matrix.api.user.MatrixUser
3540
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
41+
import io.element.android.libraries.ui.strings.CommonStrings
3642
import kotlinx.coroutines.flow.distinctUntilChanged
3743
import kotlinx.coroutines.flow.launchIn
3844
import kotlinx.coroutines.flow.map
@@ -44,6 +50,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
4450
private val client: MatrixClient,
4551
private val room: MatrixRoom,
4652
private val startDMAction: StartDMAction,
53+
private val dispatchers: CoroutineDispatchers,
54+
private val clipboardHelper: ClipboardHelper,
55+
private val snackbarDispatcher: SnackbarDispatcher,
4756
) : Presenter<UserProfileState> {
4857
interface Factory {
4958
fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
@@ -65,6 +74,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
6574
val isCurrentUser = remember { client.isMe(roomMemberId) }
6675
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
6776
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
77+
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
6878
LaunchedEffect(Unit) {
6979
client.ignoredUsersFlow
7080
.map { ignoredUsers -> roomMemberId in ignoredUsers }
@@ -112,6 +122,12 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
112122
UserProfileEvents.ClearStartDMState -> {
113123
startDmActionState.value = AsyncAction.Uninitialized
114124
}
125+
is UserProfileEvents.CopyID -> {
126+
coroutineScope.launch(dispatchers.io) {
127+
clipboardHelper.copyPlainText(event.text)
128+
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
129+
}
130+
}
115131
}
116132
}
117133

@@ -155,7 +171,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
155171
isCurrentUser = isCurrentUser,
156172
dmRoomId = dmRoomId,
157173
canCall = canCall,
158-
eventSink = ::handleEvents
174+
snackbarMessage = snackbarMessage,
175+
eventSink = ::handleEvents,
159176
)
160177
}
161178
}

features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType
2525
import io.element.android.features.roomdetails.impl.RoomTopicState
2626
import io.element.android.features.roomdetails.impl.members.aRoomMember
2727
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
28+
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
2829
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
30+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
2931
import io.element.android.libraries.featureflag.api.FeatureFlags
3032
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
3133
import io.element.android.libraries.matrix.api.core.UserId
@@ -77,11 +79,13 @@ class RoomDetailsPresenterTest {
7779
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
7880
analyticsService: AnalyticsService = FakeAnalyticsService(),
7981
isPinnedMessagesFeatureEnabled: Boolean = true,
82+
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
83+
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
8084
): RoomDetailsPresenter {
8185
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
8286
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
8387
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
84-
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction())
88+
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction(), dispatchers, clipboardHelper, snackbarDispatcher)
8589
}
8690
}
8791
val featureFlagService = FakeFeatureFlagService(
@@ -97,6 +101,8 @@ class RoomDetailsPresenterTest {
97101
dispatchers = dispatchers,
98102
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
99103
analyticsService = analyticsService,
104+
clipboardHelper = clipboardHelper,
105+
snackbarDispatcher = snackbarDispatcher,
100106
)
101107
}
102108

features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt

+12-3
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember
1919
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
2020
import io.element.android.features.userprofile.shared.UserProfileEvents
2121
import io.element.android.features.userprofile.shared.UserProfileState
22+
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
2223
import io.element.android.libraries.architecture.AsyncAction
2324
import io.element.android.libraries.architecture.AsyncData
25+
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
2426
import io.element.android.libraries.matrix.api.MatrixClient
2527
import io.element.android.libraries.matrix.api.core.UserId
2628
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -31,8 +33,10 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
3133
import io.element.android.libraries.matrix.test.FakeMatrixClient
3234
import io.element.android.libraries.matrix.ui.components.aMatrixUser
3335
import io.element.android.tests.testutils.WarmUpRule
36+
import io.element.android.tests.testutils.testCoroutineDispatchers
3437
import kotlinx.collections.immutable.persistentListOf
3538
import kotlinx.coroutines.ExperimentalCoroutinesApi
39+
import kotlinx.coroutines.test.TestScope
3640
import kotlinx.coroutines.test.runTest
3741
import org.junit.Rule
3842
import org.junit.Test
@@ -332,17 +336,22 @@ class RoomMemberDetailsPresenterTest {
332336
return awaitItem()
333337
}
334338

335-
private fun createRoomMemberDetailsPresenter(
339+
private fun TestScope.createRoomMemberDetailsPresenter(
336340
room: MatrixRoom,
337341
client: MatrixClient = FakeMatrixClient(),
338342
roomMemberId: UserId = UserId("@alice:server.org"),
339-
startDMAction: StartDMAction = FakeStartDMAction()
343+
startDMAction: StartDMAction = FakeStartDMAction(),
344+
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
345+
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
340346
): RoomMemberDetailsPresenter {
341347
return RoomMemberDetailsPresenter(
342348
roomMemberId = roomMemberId,
343349
client = client,
344350
room = room,
345-
startDMAction = startDMAction
351+
startDMAction = startDMAction,
352+
dispatchers = testCoroutineDispatchers(),
353+
clipboardHelper = clipboardHelper,
354+
snackbarDispatcher = snackbarDispatcher,
346355
)
347356
}
348357
}

0 commit comments

Comments
 (0)