Browse Source

[Message Actions] Copy events to clipboard (#665)

* Add `Copy` action for text events

* Remove 'Copy' action from the list for non-text events

* Use `@ContributesBinding` to inject `AndroidClipboardHelper`.
feature/julioromano/geocoding_api
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
cf2723ac7f
  1. 1
      app/build.gradle.kts
  2. 1
      changelog.d/663.feature
  3. 21
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  4. 14
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  5. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
  6. 11
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
  7. 1
      features/messages/impl/src/main/res/values/localazy.xml
  8. 11
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
  9. 34
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
  10. 3
      features/onboarding/impl/src/main/res/values/localazy.xml
  11. 12
      libraries/androidutils/build.gradle.kts
  12. 40
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt
  13. 25
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt
  14. 26
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt
  15. 2
      libraries/ui-strings/src/main/res/values/localazy.xml
  16. 2
      plugins/src/main/kotlin/extension/DependencyHandleScope.kt

1
app/build.gradle.kts

@ -198,7 +198,6 @@ dependencies { @@ -198,7 +198,6 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests)
implementation(projects.anvilannotations)
implementation(projects.appnav)

1
changelog.d/663.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add 'Copy' action to timeline item context menu, for text events

21
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@ -50,11 +51,13 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -50,11 +51,13 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
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.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -66,7 +69,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode @@ -66,7 +69,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
@ -79,6 +81,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -79,6 +81,7 @@ class MessagesPresenter @AssistedInject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
@Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> {
@ -155,7 +158,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -155,7 +158,7 @@ class MessagesPresenter @AssistedInject constructor(
composerState: MessageComposerState,
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
@ -246,4 +249,18 @@ class MessagesPresenter @AssistedInject constructor( @@ -246,4 +249,18 @@ class MessagesPresenter @AssistedInject constructor(
if (event.eventId == null) return
navigator.onReportContentClicked(event.eventId, event.senderId)
}
private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body
is TimelineItemStateContent -> event.content.body
else -> return
}
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_message_copied))
}
}
}

14
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

@ -33,8 +33,6 @@ import androidx.compose.foundation.layout.padding @@ -33,8 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
@ -69,18 +67,15 @@ import io.element.android.libraries.androidutils.ui.hideKeyboard @@ -69,18 +67,15 @@ import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.components.ProgressDialog
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.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
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.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
@ -255,12 +250,7 @@ fun MessagesViewTopBar( @@ -255,12 +250,7 @@ fun MessagesViewTopBar(
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
BackButton(onClick = onBackPressed)
},
title = {
Row(

5
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt

@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc @@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList
@ -64,7 +65,9 @@ class ActionListPresenter @Inject constructor( @@ -64,7 +65,9 @@ class ActionListPresenter @Inject constructor(
is TimelineItemRedactedContent,
is TimelineItemStateContent -> {
buildList {
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
@ -76,7 +79,9 @@ class ActionListPresenter @Inject constructor( @@ -76,7 +79,9 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isMine) {
add(TimelineItemAction.Edit)
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}

11
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt

@ -22,3 +22,14 @@ import androidx.compose.runtime.Immutable @@ -22,3 +22,14 @@ import androidx.compose.runtime.Immutable
sealed interface TimelineItemEventContent {
val type: String
}
/**
* Only text based content and states can be copied.
*/
fun TimelineItemEventContent.canBeCopied(): Boolean =
when (this) {
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> true
else -> false
}

1
features/messages/impl/src/main/res/values/localazy.xml

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
<string name="screen_room_message_copied">"Message copied"</string>
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>

11
features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt

@ -34,10 +34,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact @@ -34,10 +34,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@ -115,14 +117,17 @@ class MessagesPresenterTest { @@ -115,14 +117,17 @@ class MessagesPresenterTest {
@Test
fun `present - handle action copy`() = runTest {
val presenter = createMessagePresenter()
val clipboardHelper = FakeClipboardHelper()
val event = aMessageEvent()
val presenter = createMessagePresenter(clipboardHelper = clipboardHelper)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent()))
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body)
}
}
@ -355,6 +360,7 @@ class MessagesPresenterTest { @@ -355,6 +360,7 @@ class MessagesPresenterTest {
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@ -396,6 +402,7 @@ class MessagesPresenterTest { @@ -396,6 +402,7 @@ class MessagesPresenterTest {
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
clipboardHelper = clipboardHelper,
dispatchers = coroutineDispatchers,
)
}

34
features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt

@ -25,8 +25,10 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents @@ -25,8 +25,10 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.test.A_MESSAGE
@ -164,6 +166,38 @@ class ActionListPresenterTest { @@ -164,6 +166,38 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for a media item`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Developer,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)

3
features/onboarding/impl/src/main/res/values/localazy.xml

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
<string name="screen_onboarding_welcome_message">"Welcome to the fastest Element ever. Supercharged for speed and simplicity."</string>
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your Element"</string>
<string name="screen_onboarding_welcome_title">"Be in your element"</string>
</resources>

12
libraries/androidutils/build.gradle.kts

@ -16,18 +16,28 @@ @@ -16,18 +16,28 @@
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.androidutils"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(libs.dagger)
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.browser)
implementation(projects.libraries.core)
}

40
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidClipboardHelper @Inject constructor(
@ApplicationContext private val context: Context,
) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
override fun copyPlainText(text: String) {
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text))
}
}

25
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
/**
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
*/
interface ClipboardHelper {
fun copyPlainText(text: String)
}

26
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.clipboard
class FakeClipboardHelper : ClipboardHelper {
var clipboardContents: Any? = null
override fun copyPlainText(text: String) {
clipboardContents = text
}
}

2
libraries/ui-strings/src/main/res/values/localazy.xml

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
<string name="action_invite">"Invite"</string>
<string name="action_invite_friends">"Invite friends"</string>
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
<string name="action_invite_people_to_app">"Invite people to %1$s"</string>
<string name="action_invites_list">"Invites"</string>
<string name="action_learn_more">"Learn more"</string>
<string name="action_leave">"Leave"</string>
@ -108,6 +109,7 @@ @@ -108,6 +109,7 @@
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
<string name="common_shared_location">"Shared location"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>

2
plugins/src/main/kotlin/extension/DependencyHandleScope.kt

@ -75,6 +75,8 @@ private fun DependencyHandlerScope.addImplementationProjects( @@ -75,6 +75,8 @@ private fun DependencyHandlerScope.addImplementationProjects(
}
fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:androidutils"))
implementation(project(":libraries:deeplink"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:matrix:impl"))
implementation(project(":libraries:matrixui"))

Loading…
Cancel
Save