From af62f3d24a8febabbeea31e78e61ed822734678d Mon Sep 17 00:00:00 2001
From: "Jan C. Borchardt" <925062+jancborchardt@users.noreply.github.com>
Date: Wed, 3 Jun 2026 17:56:27 +0200
Subject: [PATCH] feat(chat): add "Copy message link" to message actions
Add a "Copy message link" option to the long-press message actions
bottom sheet. It copies a link in the same format the web client uses
({baseUrl}/call/{token}#message_{messageId}) to the clipboard, so the
link can be shared (e.g. by email) and opened in the browser, landing
on the referenced message.
Resolves: https://github.com/nextcloud/talk-android/issues/2361
Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com>
---
.../com/nextcloud/talk/chat/ChatActivity.kt | 22 +++++++
.../talk/chat/ui/MessageActionsBottomSheet.kt | 20 ++++++
.../java/com/nextcloud/talk/utils/UriUtils.kt | 7 +++
app/src/main/res/drawable/ic_open_in_new.xml | 16 +++++
app/src/main/res/values/strings.xml | 1 +
.../talk/chat/ui/MessageActionsStateTest.kt | 62 +++++++++++++++++++
.../com/nextcloud/talk/utils/UriUtilsTest.kt | 31 ++++++++++
7 files changed, 159 insertions(+)
create mode 100644 app/src/main/res/drawable/ic_open_in_new.xml
create mode 100644 app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
create mode 100644 app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt
diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
index 7807525e19e..8508cdc46f2 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
@@ -183,6 +183,7 @@ import com.nextcloud.talk.utils.Mimetype
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.utils.SpreedFeatures
+import com.nextcloud.talk.utils.UriUtils
import com.nextcloud.talk.utils.VibrationUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
@@ -947,6 +948,7 @@ class ChatActivity :
onForward = { forwardMessage(msg) },
onEdit = { messageInputViewModel.edit(msg) },
onCopy = { copyMessage(msg) },
+ onCopyMessageLink = { copyMessageLink(msg) },
onMarkAsUnread = { markAsUnread(msg) },
onRemind = { remindMeLater(msg) },
onPin = { pinMessage(msg) },
@@ -3515,6 +3517,26 @@ class ChatActivity :
clipboardManager.setPrimaryClip(clipData)
}
+ fun copyMessageLink(message: ChatMessage) {
+ val baseUrl = conversationUser?.baseUrl
+ if (baseUrl.isNullOrEmpty()) {
+ Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
+ return
+ }
+
+ val messageLink = UriUtils.getMessageLink(baseUrl, roomToken, message.jsonMessageId.toLong())
+
+ val clipboardManager =
+ getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = ClipData.newPlainText(
+ resources?.getString(R.string.nc_app_product_name),
+ messageLink
+ )
+ clipboardManager.setPrimaryClip(clipData)
+
+ Snackbar.make(binding.root, R.string.nc_common_copy_success, Snackbar.LENGTH_SHORT).show()
+ }
+
fun translateMessage(message: ChatMessage?) {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_TRANSLATE_MESSAGE, message?.getRichText())
diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
index 8ec37b1f7f4..e2d1e34871b 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
@@ -129,6 +129,7 @@ data class MessageActionsState(
val showForward: Boolean,
val showEdit: Boolean,
val showCopy: Boolean,
+ val showCopyMessageLink: Boolean,
val showMarkAsUnread: Boolean,
val showRemind: Boolean,
val showPin: Boolean,
@@ -242,6 +243,8 @@ internal fun buildMessageActionsState(
isOnline,
showEdit = isMessageEditable,
showCopy = !message.isDeleted,
+ showCopyMessageLink = !message.isDeleted &&
+ ChatMessage.MessageType.SYSTEM_MESSAGE != messageType,
showMarkAsUnread = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CHAT_READ_MARKER) &&
ChatMessage.MessageType.SYSTEM_MESSAGE != messageType &&
isOnline,
@@ -279,6 +282,7 @@ fun MessageActionsBottomSheet(
onForward: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
+ onCopyMessageLink: () -> Unit,
onMarkAsUnread: () -> Unit,
onRemind: () -> Unit,
onPin: () -> Unit,
@@ -306,6 +310,7 @@ fun MessageActionsBottomSheet(
onForward = onForward,
onEdit = onEdit,
onCopy = onCopy,
+ onCopyMessageLink = onCopyMessageLink,
onMarkAsUnread = onMarkAsUnread,
onRemind = onRemind,
onPin = onPin,
@@ -332,6 +337,7 @@ internal fun MessageActionsSheetContent(
onForward: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
+ onCopyMessageLink: () -> Unit,
onMarkAsUnread: () -> Unit,
onRemind: () -> Unit,
onPin: () -> Unit,
@@ -436,6 +442,16 @@ internal fun MessageActionsSheetContent(
}
)
}
+ if (actionsState.showCopyMessageLink) {
+ MessageActionItem(
+ iconRes = R.drawable.ic_open_in_new,
+ text = stringResource(R.string.nc_copy_message_link),
+ onClick = {
+ onCopyMessageLink()
+ onDismiss()
+ }
+ )
+ }
if (actionsState.showMarkAsUnread) {
MessageActionItem(
iconRes = R.drawable.ic_mark_chat_unread_24px,
@@ -808,6 +824,7 @@ private fun PreviewMessageActionsSheetContent() {
showForward = true,
showEdit = true,
showCopy = true,
+ showCopyMessageLink = true,
showMarkAsUnread = true,
showRemind = true,
showPin = true,
@@ -830,6 +847,7 @@ private fun PreviewMessageActionsSheetContent() {
onForward = {},
onEdit = {},
onCopy = {},
+ onCopyMessageLink = {},
onMarkAsUnread = {},
onRemind = {},
onPin = {},
@@ -864,6 +882,7 @@ private fun PreviewMessageActionsSheetPinned() {
showForward = false,
showEdit = false,
showCopy = true,
+ showCopyMessageLink = true,
showMarkAsUnread = false,
showRemind = false,
showPin = true,
@@ -882,6 +901,7 @@ private fun PreviewMessageActionsSheetPinned() {
onForward = {},
onEdit = {},
onCopy = {},
+ onCopyMessageLink = {},
onMarkAsUnread = {},
onRemind = {},
onPin = {},
diff --git a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt
index 2ef5726a9fd..955d836259d 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt
@@ -41,6 +41,13 @@ class UriUtils {
return url.toUri().lastPathSegment ?: ""
}
+ /**
+ * Builds a shareable web link to a specific chat message, matching the web client format:
+ * {baseUrl}/call/{token}#message_{messageId}
+ */
+ fun getMessageLink(baseUrl: String, roomToken: String, messageId: Long): String =
+ "${baseUrl.trimEnd('/')}/call/$roomToken#message_$messageId"
+
fun isInstanceInternalFileUrl(baseUrl: String, url: String): Boolean {
// https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41
return (
diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml
new file mode 100644
index 00000000000..da6d10d5a7b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_open_in_new.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c5996722ca1..7d8703eb52b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -563,6 +563,7 @@ How to translate with transifex:
Copy
+ Copy message link
Forward
Reply
Reply privately
diff --git a/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
new file mode 100644
index 00000000000..dc98586731b
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
@@ -0,0 +1,62 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat.ui
+
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.capabilities.SpreedCapability
+import com.nextcloud.talk.utils.DateUtils
+import org.junit.Assert
+import org.junit.Test
+import org.mockito.Mockito
+
+class MessageActionsStateTest {
+
+ private val dateUtils = Mockito.mock(DateUtils::class.java)
+
+ private fun buildStateFor(message: ChatMessage) =
+ buildMessageActionsState(
+ message = message,
+ user = User().apply { userId = "alice" },
+ conversation = null,
+ hasChatPermission = true,
+ hasReactPermission = true,
+ spreedCapabilities = SpreedCapability(),
+ isOnline = true,
+ dateUtils = dateUtils,
+ conversationThreadId = null
+ )
+
+ @Test
+ fun showCopyMessageLink_trueForRegularMessage() {
+ val msg = ChatMessage().apply {
+ jsonMessageId = 4
+ message = "Hello world"
+ }
+ Assert.assertTrue(buildStateFor(msg).showCopyMessageLink)
+ }
+
+ @Test
+ fun showCopyMessageLink_falseForDeletedMessage() {
+ val msg = ChatMessage().apply {
+ jsonMessageId = 4
+ message = "Hello world"
+ isDeleted = true
+ }
+ Assert.assertFalse(buildStateFor(msg).showCopyMessageLink)
+ }
+
+ @Test
+ fun showCopyMessageLink_falseForSystemMessage() {
+ // isSystemMessage is computed at construction time, so the type must be set via the constructor
+ val msg = ChatMessage(
+ jsonMessageId = 4,
+ systemMessageType = ChatMessage.SystemMessageType.CONVERSATION_CREATED
+ )
+ Assert.assertFalse(buildStateFor(msg).showCopyMessageLink)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt
new file mode 100644
index 00000000000..37ba993f26b
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt
@@ -0,0 +1,31 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.utils
+
+import org.junit.Assert
+import org.junit.Test
+
+class UriUtilsTest {
+
+ @Test
+ fun getMessageLink_buildsWebClientFormat() {
+ val link = UriUtils.getMessageLink("https://cloud.example.com", "mwcskgpz", 4)
+ Assert.assertEquals("https://cloud.example.com/call/mwcskgpz#message_4", link)
+ }
+
+ @Test
+ fun getMessageLink_trimsTrailingSlashOnBaseUrl() {
+ val link = UriUtils.getMessageLink("https://cloud.example.com/", "abc123", 42)
+ Assert.assertEquals("https://cloud.example.com/call/abc123#message_42", link)
+ }
+
+ @Test
+ fun getMessageLink_keepsSubpathBaseUrl() {
+ val link = UriUtils.getMessageLink("https://example.com/nextcloud", "token1", 7)
+ Assert.assertEquals("https://example.com/nextcloud/call/token1#message_7", link)
+ }
+}