diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a5ca187027..333a9e5793 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -198,7 +198,6 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
- implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests)
implementation(projects.anvilannotations)
implementation(projects.appnav)
diff --git a/changelog.d/663.feature b/changelog.d/663.feature
new file mode 100644
index 0000000000..daabb76560
--- /dev/null
+++ b/changelog.d/663.feature
@@ -0,0 +1 @@
+Add 'Copy' action to timeline item context menu, for text events
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 145ac2d238..55324613ed 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -38,4 +38,4 @@
"Password"
"Continue"
"Username"
-
\ No newline at end of file
+
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index e305b916cd..01c3dc12d1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -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
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
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(
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
+ private val clipboardHelper: ClipboardHelper,
@Assisted private val navigator: MessagesNavigator,
) : Presenter {
@@ -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(
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))
+ }
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index d9504894e8..8e2558770d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -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
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(
TopAppBar(
modifier = modifier,
navigationIcon = {
- IconButton(onClick = onBackPressed) {
- Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Back"
- )
- }
+ BackButton(onClick = onBackPressed)
},
title = {
Row(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index 56de0214ec..26ab4fc217 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/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
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(
is TimelineItemRedactedContent,
is TimelineItemStateContent -> {
buildList {
- add(TimelineItemAction.Copy)
+ if (timelineItem.content.canBeCopied()) {
+ add(TimelineItemAction.Copy)
+ }
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
@@ -76,7 +79,9 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isMine) {
add(TimelineItemAction.Edit)
}
- add(TimelineItemAction.Copy)
+ if (timelineItem.content.canBeCopied()) {
+ add(TimelineItemAction.Copy)
+ }
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index 233f51a5a2..0ff67e481f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/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
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
+ }
diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml
index 557b6ccd90..508acf0e33 100644
--- a/features/messages/impl/src/main/res/values/localazy.xml
+++ b/features/messages/impl/src/main/res/values/localazy.xml
@@ -12,6 +12,7 @@
"Could not retrieve user details"
"Would you like to invite them back?"
"You are alone in this chat"
+ "Message copied"
"You do not have permission to post to this room"
"Send again"
"Your message failed to send"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
index 40af424406..8ec76c6aa4 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
+++ b/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
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 {
@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 {
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
+ clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@@ -396,6 +402,7 @@ class MessagesPresenterTest {
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
+ clipboardHelper = clipboardHelper,
dispatchers = coroutineDispatchers,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
index 7af17d193f..88334737d7 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
+++ b/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
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 {
}
}
+ @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)
diff --git a/features/onboarding/impl/src/main/res/values/localazy.xml b/features/onboarding/impl/src/main/res/values/localazy.xml
index bad5b524da..cdb258cdad 100644
--- a/features/onboarding/impl/src/main/res/values/localazy.xml
+++ b/features/onboarding/impl/src/main/res/values/localazy.xml
@@ -4,6 +4,7 @@
"Sign in with QR code"
"Create account"
"Communicate and collaborate securely"
+ "Welcome to the fastest Element ever. Supercharged for speed and simplicity."
"Welcome to %1$s. Supercharged, for speed and simplicity."
- "Be in your Element"
+ "Be in your element"
diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts
index f98914e08a..92e3c46126 100644
--- a/libraries/androidutils/build.gradle.kts
+++ b/libraries/androidutils/build.gradle.kts
@@ -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)
}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt
new file mode 100644
index 0000000000..cecf47eb1b
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt
@@ -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())
+
+ override fun copyPlainText(text: String) {
+ clipboardManager.setPrimaryClip(ClipData.newPlainText("", text))
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt
new file mode 100644
index 0000000000..39cb719d48
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt
@@ -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)
+
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt
new file mode 100644
index 0000000000..03cd70c768
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt
@@ -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
+ }
+}
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index ffbc7aded4..bcd90cb160 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -28,6 +28,7 @@
"Invite"
"Invite friends"
"Invite friends to %1$s"
+ "Invite people to %1$s"
"Invites"
"Learn more"
"Leave"
@@ -108,6 +109,7 @@
"Server not supported"
"Server URL"
"Settings"
+ "Shared location"
"Starting chat…"
"Sticker"
"Success"
diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
index 51c568960d..88f499b993 100644
--- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
+++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
@@ -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"))