From afbd6651362406b4d42589de624defa33ab8d5f2 Mon Sep 17 00:00:00 2001 From: sowjanyakch Date: Tue, 30 Jun 2026 17:39:05 +0200 Subject: [PATCH 1/3] support markdown tasks fulfil edit requirements Signed-off-by: Julius Linus --- .../com/nextcloud/talk/chat/ChatActivity.kt | 74 +++++++++++- .../talk/ui/chat/ChatMessageScaffold.kt | 1 + .../nextcloud/talk/ui/chat/ChatMessageView.kt | 35 +++--- .../com/nextcloud/talk/ui/chat/ChatView.kt | 3 +- .../nextcloud/talk/ui/chat/MarkdownText.kt | 108 +++++++++++++++++- 5 files changed, 201 insertions(+), 20 deletions(-) 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 7e17316b0df..4c63b283471 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -949,7 +949,10 @@ class ChatActivity : chatViewModel.toggleSystemMessageCollapse(messageId) }, onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) }, - onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) } + onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) }, + onMarkdownTaskToggle = { messageId, updatedMessage -> + updateMarkdownTaskMessage(messageId, updatedMessage) + } ) ), listState = listState @@ -1205,6 +1208,74 @@ class ChatActivity : } } + private fun updateMarkdownTaskMessage(messageId: Int, updatedMessage: String) { + if (credentials.isNullOrBlank() || conversationUser?.baseUrl.isNullOrBlank()) { + return + } + + lifecycleScope.launch { + val message = chatViewModel.getMessageById(messageId.toLong()).first() + if (!canEditMarkdownTaskMessage(message)) { + return@launch + } + if (message.isTemporary) { + messageInputViewModel.editTempChatMessage(message, updatedMessage) + } else { + val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + messageInputViewModel.editChatMessage( + credentials!!, + ApiUtils.getUrlForChatMessage( + version = apiVersion, + baseUrl = conversationUser!!.baseUrl!!, + token = roomToken, + messageId = message.jsonMessageId.toString() + ), + getMarkdownTaskEditText(message, updatedMessage) + ) + } + } + } + + private fun canEditMarkdownTaskMessage(message: ChatMessage): Boolean { + if (message.isTemporary) { + return true + } + val isOlderThanTwentyFourHours = message.createdAt + .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) + if (isOlderThanTwentyFourHours || message.isDeleted) { + return false + } + if (!hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES)) { + return false + } + if (message.getCalculateMessageType() != ChatMessage.MessageType.REGULAR_TEXT_MESSAGE) { + return false + } + + return message.actorId == conversationUser?.userId || + currentConversation?.let { ConversationUtils.canModerate(it, spreedCapabilities) } == true + } + + private fun getMarkdownTaskEditText(message: ChatMessage, updatedMessage: String): String { + val messageParameters = message.messageParameters ?: return updatedMessage + var result = updatedMessage + for ((key, params) in messageParameters) { + val token = "{$key}" + if (!result.contains(token)) { + continue + } + + val replacement = when (params?.get("type")) { + "user", "guest", "email" -> "@${params["mention-id"]}" + "user-group", "circle" -> "@\"${params["mention-id"]}\"" + "call" -> "@all" + else -> params?.get("name").orEmpty() + } + result = result.replace(token, replacement) + } + return result + } + @Composable private fun LazyListState.visibleItemsWithThreshold(): List = remember(this) { @@ -4279,6 +4350,7 @@ class ChatActivity : private const val SEARCH_CENTER_TOLERANCE_PX = 2f private const val SEARCH_CENTER_STABILIZE_ATTEMPTS = 8 private const val SEARCH_CENTER_STABILIZE_DELAY_MS = 200L + private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000 } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index 58cf5c100c2..85a552cde75 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -105,6 +105,7 @@ internal val LocalMessageLongClickHandler = compositionLocalOf<(Int) -> Unit> { internal val LocalHighlightSearchTerm = compositionLocalOf { null } internal val LocalShowThreadButton = compositionLocalOf { true } internal val LocalAvatarClickHandler = compositionLocalOf<(Int) -> Unit> { {} } +internal val LocalMarkdownTaskToggleHandler = compositionLocalOf<(Int, String) -> Unit> { { _, _ -> } } private enum class MetadataLayoutMode { CAPTION, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt index 40304d0f7fa..7b0c6d3965a 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.nextcloud.talk.R import com.nextcloud.talk.chat.ui.model.ChatMessageUi @@ -73,7 +75,8 @@ data class ChatMessageCallbacks( val onQuotedMessageClick: (Int) -> Unit = {}, val onSystemMessageExpandClick: (Int) -> Unit = {}, val onAvatarClick: (Int) -> Unit = {}, - val onCancelUpload: (String) -> Unit = {} + val onCancelUpload: (String) -> Unit = {}, + val onMarkdownTaskToggle: (Int, String) -> Unit = { _, _ -> } ) @Suppress("Detekt.LongParameterList", "Detekt.LongMethod", "Detekt.CyclomaticComplexMethod") @@ -109,28 +112,30 @@ fun ChatMessageView( LocalOpenThreadHandler provides callbacks.onOpenThreadClick, LocalQuotedMessageClickHandler provides callbacks.onQuotedMessageClick, LocalHighlightSearchTerm provides highlightSearchTerm, - LocalAvatarClickHandler provides callbacks.onAvatarClick + LocalAvatarClickHandler provides callbacks.onAvatarClick, + LocalMarkdownTaskToggleHandler provides callbacks.onMarkdownTaskToggle ) { SwipeToReplyContainer( replyable = message.replyable && context.hasChatPermission, onSwipeReply = { callbacks.onSwipeReply?.invoke(message.id) } ) { + val messageGestureModifier = if (message.isExpandableParent) { + Modifier.combinedClickable( + interactionSource = interactionSource, + indication = ripple(), + onClick = { callbacks.onSystemMessageExpandClick(message.id) }, + onLongClick = { callbacks.onLongClick?.invoke(message.id) } + ) + } else { + Modifier.pointerInput(message.id) { + detectTapGestures(onLongPress = { callbacks.onLongClick?.invoke(message.id) }) + } + } + Box( modifier = Modifier .fillMaxWidth() - .combinedClickable( - interactionSource = interactionSource, - indication = ripple(), - onClick = { - if (message.isExpandableParent) { - callbacks.onSystemMessageExpandClick(message.id) - } else { - callbacks.onLongClick?.invoke(message.id) - } - }, - onDoubleClick = { callbacks.onLongClick?.invoke(message.id) }, - onLongClick = { callbacks.onLongClick?.invoke(message.id) } - ) + .then(messageGestureModifier) ) { Box(modifier = Modifier.padding(horizontal = 12.dp)) { when (val content = message.content) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 7cac154d7d4..5b537acfbdb 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -428,7 +428,8 @@ fun ChatView( onQuotedMessageClick = handleQuotedMessageClick, onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick, onAvatarClick = callbacks.messageCallbacks.onAvatarClick, - onCancelUpload = callbacks.messageCallbacks.onCancelUpload + onCancelUpload = callbacks.messageCallbacks.onCancelUpload, + onMarkdownTaskToggle = callbacks.messageCallbacks.onMarkdownTaskToggle ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt index 09f0ec79285..7fe7d5e89dd 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt @@ -72,6 +72,7 @@ private const val CHIP_START_PADDING_DP = 2f private const val CHIP_END_PADDING_DP = 5f private const val CHIP_VERTICAL_PADDING_DP = 2f private const val CHIP_CORNER_RADIUS_DP = 16f +private const val TASK_CHECKBOX_TOUCH_TARGET_DP = 48f // GFM table separator row: starts with | followed by optional spaces/colons and at least 3 dashes private val TABLE_SEPARATOR_REGEX = Regex("""^\|[ :]*-{3,}""", RegexOption.MULTILINE) @@ -81,6 +82,8 @@ val validLinkRegex = Regex( RegexOption.IGNORE_CASE ) +private val MARKDOWN_TASK_LINE_REGEX = Regex("""^(\s*(?:[-*+]\s+|\d+[.)]\s+)\[)([ xX])(]\s+.*)$""") + @Suppress("LongMethod", "LongParameterList") @Composable fun MarkdownText( @@ -108,7 +111,11 @@ fun MarkdownText( val avatarGapPx = with(density) { AVATAR_GAP_DP.dp.toPx() } val messageId = message.id val onMessageLongClick = LocalMessageLongClickHandler.current + val onMarkdownTaskToggle = LocalMarkdownTaskToggleHandler.current val onLongClickState = rememberUpdatedState(onMessageLongClick) + val hasMarkdownTasks = remember(message.plainMessage) { + message.plainMessage.lineSequence().any { MARKDOWN_TASK_LINE_REGEX.matches(it) } + } val hasTable = remember(message.plainMessage) { message.plainMessage.contains(TABLE_SEPARATOR_REGEX) } @@ -138,8 +145,37 @@ fun MarkdownText( } ) val longPressListener = View.OnTouchListener { view, event -> - if (event.action == MotionEvent.ACTION_UP) { - view.performClick() + val textView = view as? LongPressTextView ?: return@OnTouchListener false + val currentMessage = textView.currentMessage ?: return@OnTouchListener false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val taskLine = findMarkdownTaskLineAtTouch( + textView = textView, + event = event, + message = currentMessage, + requireCheckboxHit = true + ) + textView.pendingMarkdownTaskLine = taskLine + if (taskLine != null) { + return@OnTouchListener true + } + } + + MotionEvent.ACTION_UP -> { + val pendingTaskLine = textView.pendingMarkdownTaskLine + textView.pendingMarkdownTaskLine = null + if (pendingTaskLine != null) { + handleMarkdownTaskToggle( + message = currentMessage, + clickedSourceLine = pendingTaskLine, + onMarkdownTaskToggle = textView.onMarkdownTaskToggle + ) + return@OnTouchListener true + } + view.performClick() + } + + MotionEvent.ACTION_CANCEL -> textView.pendingMarkdownTaskLine = null } gestureDetector.onTouchEvent(event) false @@ -155,6 +191,8 @@ fun MarkdownText( } }, update = { textView -> + textView.currentMessage = message + textView.onMarkdownTaskToggle = onMarkdownTaskToggle textView.setTextColor(textColorArgb) textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp) textView.maxLines = maxLines @@ -214,7 +252,8 @@ fun MarkdownText( applySearchHighlight(ssb, highlightSearchTerm, searchHighlightColorArgb) markwon.setParsedMarkdown(textView, ssb) textView.setLinkTextColor(linkColorArgb) - val needsMovementMethod = (hasClickableChips || hasLinks) && maxLines == Int.MAX_VALUE + val needsMovementMethod = (hasClickableChips || hasLinks || hasMarkdownTasks) && + maxLines == Int.MAX_VALUE if (needsMovementMethod) { textView.movementMethod = LinkMovementMethod.getInstance() textView.setOnTouchListener(textView.tag as? View.OnTouchListener) @@ -254,6 +293,66 @@ private fun applySearchHighlight(spannable: SpannableStringBuilder, searchTerm: } } +private fun findMarkdownTaskLineAtTouch( + textView: TextView, + event: MotionEvent, + message: ChatMessageUi, + requireCheckboxHit: Boolean +): Int? { + if (!message.renderMarkdown || message.isDeleted || message.plainMessage.isBlank()) { + return null + } + + val layout = textView.layout ?: return null + val verticalPosition = (event.y - textView.totalPaddingTop + textView.scrollY).roundToInt() + val clickedRenderedLine = layout.getLineForVertical(verticalPosition) + if (requireCheckboxHit && !isInsideTaskCheckboxTouchTarget(textView, event, layout, clickedRenderedLine)) { + return null + } + + val renderedText = textView.text?.toString().orEmpty() + val clickedSourceLine = renderedText + .take(layout.getLineStart(clickedRenderedLine).coerceAtMost(renderedText.length)) + .count { it == '\n' } + + return clickedSourceLine.takeIf { sourceLineIndex -> + message.plainMessage + .lineSequence() + .elementAtOrNull(sourceLineIndex) + ?.let { MARKDOWN_TASK_LINE_REGEX.matchEntire(it) } != null + } +} + +private fun isInsideTaskCheckboxTouchTarget( + textView: TextView, + event: MotionEvent, + layout: android.text.Layout, + clickedRenderedLine: Int +): Boolean { + val density = textView.resources.displayMetrics.density + val horizontalPosition = event.x - textView.totalPaddingLeft + textView.scrollX + val lineStart = layout.getLineStart(clickedRenderedLine) + val textStart = layout.getPrimaryHorizontal(lineStart) + val touchTargetStart = (textStart - TASK_CHECKBOX_TOUCH_TARGET_DP * density).coerceAtLeast(0f) + val touchTargetEnd = textStart + TASK_CHECKBOX_TOUCH_TARGET_DP * density / 2 + return horizontalPosition in touchTargetStart..touchTargetEnd +} + +private fun handleMarkdownTaskToggle( + message: ChatMessageUi, + clickedSourceLine: Int, + onMarkdownTaskToggle: (Int, String) -> Unit +) { + val sourceLines = message.plainMessage.split('\n') + val sourceLine = sourceLines.getOrNull(clickedSourceLine) ?: return + val match = MARKDOWN_TASK_LINE_REGEX.matchEntire(sourceLine) ?: return + val replacement = if (match.groupValues[2].equals("x", ignoreCase = true)) " " else "x" + val updatedLine = match.groupValues[1] + replacement + match.groupValues[3] + val updatedMessage = sourceLines.toMutableList().also { it[clickedSourceLine] = updatedLine }.joinToString("\n") + + onMarkdownTaskToggle(message.id, updatedMessage) +} + private fun resolveNonMentionParams(message: ChatMessageUi): String { var result = message.plainMessage for ((key, params) in message.messageParameters) { @@ -500,6 +599,9 @@ private class MentionChipSpan( } private class LongPressTextView(context: Context) : AppCompatTextView(context) { + var currentMessage: ChatMessageUi? = null + var onMarkdownTaskToggle: (Int, String) -> Unit = { _, _ -> } + var pendingMarkdownTaskLine: Int? = null override fun performClick(): Boolean { super.performClick() return true From b9d88a7c25f63a2c04b1c8d818608f65396c02c1 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 21 Jul 2026 10:20:04 -0500 Subject: [PATCH 2/3] linter Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/ui/chat/MarkdownText.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt index 7fe7d5e89dd..7387f2fffc9 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt @@ -73,16 +73,17 @@ private const val CHIP_END_PADDING_DP = 5f private const val CHIP_VERTICAL_PADDING_DP = 2f private const val CHIP_CORNER_RADIUS_DP = 16f private const val TASK_CHECKBOX_TOUCH_TARGET_DP = 48f +private const val INT_3 = 3 // GFM table separator row: starts with | followed by optional spaces/colons and at least 3 dashes -private val TABLE_SEPARATOR_REGEX = Regex("""^\|[ :]*-{3,}""", RegexOption.MULTILINE) +private val tableSeparatorRegex = Regex("""^\|[ :]*-{3,}""", RegexOption.MULTILINE) val validLinkRegex = Regex( """(? Date: Tue, 8 Sep 2026 12:09:29 -0500 Subject: [PATCH 3/3] fix(markdown-tasks): update checkbox locally and align edit gating Toggling a task-list checkbox now updates the local message immediately instead of waiting for the server round-trip, and reverts if the edit is later rejected. Also unify the checkbox's editability check with the one used by the "Edit" action, so a message that is editable (e.g. in Note-to-Self conversations with no time limit) can also have its checkboxes toggled. Co-Authored-By: Claude Sonnet 5 Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 77 +++++++++++++------ .../talk/chat/ui/MessageActionsBottomSheet.kt | 57 ++++++++++---- 2 files changed, 97 insertions(+), 37 deletions(-) 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 4c63b283471..883cb83b687 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -124,6 +124,7 @@ import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet import com.nextcloud.talk.chat.ui.TempMessageActionsBottomSheet import com.nextcloud.talk.chat.ui.TypingIndicatorBanner import com.nextcloud.talk.chat.ui.buildMessageActionsState +import com.nextcloud.talk.chat.ui.isMessageEditable import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel @@ -1218,21 +1219,29 @@ class ChatActivity : if (!canEditMarkdownTaskMessage(message)) { return@launch } + + val originalMessageText = message.message.orEmpty() + + // Apply the toggle to the local copy right away so the checkbox reflects the new + // state immediately, instead of waiting for the server round-trip to complete. + messageInputViewModel.editTempChatMessage(message, updatedMessage) + if (message.isTemporary) { - messageInputViewModel.editTempChatMessage(message, updatedMessage) - } else { - val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - messageInputViewModel.editChatMessage( - credentials!!, - ApiUtils.getUrlForChatMessage( - version = apiVersion, - baseUrl = conversationUser!!.baseUrl!!, - token = roomToken, - messageId = message.jsonMessageId.toString() - ), - getMarkdownTaskEditText(message, updatedMessage) - ) + return@launch } + + pendingMarkdownTaskEdit = PendingMarkdownTaskEdit(messageId, originalMessageText) + val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + messageInputViewModel.editChatMessage( + credentials!!, + ApiUtils.getUrlForChatMessage( + version = apiVersion, + baseUrl = conversationUser!!.baseUrl!!, + token = roomToken, + messageId = message.jsonMessageId.toString() + ), + getMarkdownTaskEditText(message, updatedMessage) + ) } } @@ -1240,20 +1249,35 @@ class ChatActivity : if (message.isTemporary) { return true } - val isOlderThanTwentyFourHours = message.createdAt - .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) - if (isOlderThanTwentyFourHours || message.isDeleted) { - return false - } - if (!hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES)) { - return false - } if (message.getCalculateMessageType() != ChatMessage.MessageType.REGULAR_TEXT_MESSAGE) { return false } - return message.actorId == conversationUser?.userId || + val isUserAllowedByPrivileges = message.actorId == conversationUser?.userId || currentConversation?.let { ConversationUtils.canModerate(it, spreedCapabilities) } == true + val hasChatPermission = participantPermissionsFlow.value?.hasChatPermission() == true + + return isMessageEditable( + message = message, + conversation = currentConversation, + hasChatPermission = hasChatPermission, + isUserAllowedByPrivileges = isUserAllowedByPrivileges, + spreedCapabilities = spreedCapabilities + ) + } + + // Tracks a checkbox toggle sent to the server so it can be rolled back locally if the + // edit is rejected (e.g. message became too old, or read-only conversation). + private data class PendingMarkdownTaskEdit(val messageId: Int, val originalMessage: String) + private var pendingMarkdownTaskEdit: PendingMarkdownTaskEdit? = null + + private fun revertPendingMarkdownTaskEdit() { + val pending = pendingMarkdownTaskEdit ?: return + pendingMarkdownTaskEdit = null + lifecycleScope.launch { + val message = chatViewModel.getMessageById(pending.messageId.toLong()).first() + messageInputViewModel.editTempChatMessage(message, pending.originalMessage) + } } private fun getMarkdownTaskEditText(message: ChatMessage, updatedMessage: String): String { @@ -1860,6 +1884,7 @@ class ChatActivity : is MessageInputViewModel.EditMessageSuccessState -> { when (state.messageEdited.ocs?.meta?.statusCode) { HTTP_BAD_REQUEST -> { + revertPendingMarkdownTaskEdit() Snackbar.make( binding.root, getString(R.string.edit_error_24_hours_old_message), @@ -1868,6 +1893,7 @@ class ChatActivity : } HTTP_FORBIDDEN -> { + revertPendingMarkdownTaskEdit() Snackbar.make( binding.root, getString(R.string.conversation_is_read_only), @@ -1876,16 +1902,22 @@ class ChatActivity : } HTTP_NOT_FOUND -> { + revertPendingMarkdownTaskEdit() Snackbar.make( binding.root, "Conversation not found", Snackbar.LENGTH_LONG ).show() } + + else -> { + pendingMarkdownTaskEdit = null + } } } is MessageInputViewModel.EditMessageErrorState -> { + revertPendingMarkdownTaskEdit() Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } @@ -4350,7 +4382,6 @@ class ChatActivity : private const val SEARCH_CENTER_TOLERANCE_PX = 2f private const val SEARCH_CENTER_STABILIZE_ATTEMPTS = 8 private const val SEARCH_CENTER_STABILIZE_DELAY_MS = 200L - private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000 } } 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 c28bb2cf29c..cc54ae6f760 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 @@ -140,6 +140,42 @@ data class MessageActionsState( val showDelete: Boolean ) +/** + * Single source of truth for whether a message may be edited, shared by the "Edit" action in + * [buildMessageActionsState] and the markdown task-list checkbox toggle in ChatActivity, so both + * agree on time limits, note-to-self/bot exceptions and permissions. + */ +@Suppress("LongParameterList") +internal fun isMessageEditable( + message: ChatMessage, + conversation: ConversationModel?, + hasChatPermission: Boolean, + isUserAllowedByPrivileges: Boolean, + spreedCapabilities: SpreedCapability +): Boolean { + val messageType = message.getCalculateMessageType() + val messageHasFileAttachment = ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == messageType + val messageHasRegularText = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == messageType && !message.isDeleted + val messageHasCaptions = messageHasFileAttachment && message.message != "{file}" && !message.isDeleted + + val isOlderThanTwentyFourHours = message.createdAt + .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) + + val isNoTimeLimitOnNoteToSelf = + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES_NOTE_TO_SELF) && + conversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF + val isMessageBotOneToOne = message.actorType == ACTOR_BOTS && + (message.isOneToOneConversation || message.isFormerOneToOneConversation) && + !isOlderThanTwentyFourHours + val messageIsEditable = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES) && + (messageHasRegularText || messageHasCaptions) && + !isOlderThanTwentyFourHours && + isUserAllowedByPrivileges && + hasChatPermission + + return isNoTimeLimitOnNoteToSelf || messageIsEditable || isMessageBotOneToOne +} + @Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod") internal fun buildMessageActionsState( message: ChatMessage, @@ -157,8 +193,6 @@ internal fun buildMessageActionsState( val messageHasRegularText = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == messageType && !message.isDeleted val messageHasCaptions = messageHasFileAttachment && message.message != "{file}" && !message.isDeleted - val isOlderThanTwentyFourHours = message.createdAt - .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) val isOlderThanSixHours = message.createdAt .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_DELETE_MESSAGE)) @@ -172,18 +206,13 @@ internal fun buildMessageActionsState( false } - val isNoTimeLimitOnNoteToSelf = - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES_NOTE_TO_SELF) && - conversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF - val isMessageBotOneToOne = message.actorType == ACTOR_BOTS && - (message.isOneToOneConversation || message.isFormerOneToOneConversation) && - !isOlderThanTwentyFourHours - val messageIsEditable = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES) && - (messageHasRegularText || messageHasCaptions) && - !isOlderThanTwentyFourHours && - isUserAllowedByPrivileges && - hasChatPermission - val isMessageEditable = isNoTimeLimitOnNoteToSelf || messageIsEditable || isMessageBotOneToOne + val isMessageEditable = isMessageEditable( + message = message, + conversation = conversation, + hasChatPermission = hasChatPermission, + isUserAllowedByPrivileges = isUserAllowedByPrivileges, + spreedCapabilities = spreedCapabilities + ) val hasDeleteMessagesUnlimitedCapability = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.DELETE_MESSAGES_UNLIMITED)