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) + } +}