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 8b6d7ba0ab..cf1ba767f3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -846,7 +846,10 @@ class ChatActivity : onSystemMessageExpandClick = { messageId -> chatViewModel.toggleSystemMessageCollapse(messageId) }, - onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) } + onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) }, + onMarkdownTaskToggle = { messageId, updatedMessage -> + updateMarkdownTaskMessage(messageId, updatedMessage) + } ) ), listState = listState @@ -974,6 +977,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) { @@ -4013,5 +4084,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/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index fec9543946..46150542b3 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 @@ -100,6 +100,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 f439969e9d..1f6f2be2b3 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 onOpenThreadClick: (Int) -> Unit = {}, val onQuotedMessageClick: (Int) -> Unit = {}, val onSystemMessageExpandClick: (Int) -> Unit = {}, - val onAvatarClick: (Int) -> Unit = {} + val onAvatarClick: (Int) -> 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 bc57a4a163..c0ec5e9e6d 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( onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, onQuotedMessageClick = handleQuotedMessageClick, onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick, - onAvatarClick = callbacks.messageCallbacks.onAvatarClick + onAvatarClick = callbacks.messageCallbacks.onAvatarClick, + 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 957b494608..aa0edc20a0 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 @@ -65,6 +65,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) @@ -74,6 +75,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( @@ -101,7 +104,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) } @@ -127,8 +134,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 @@ -140,6 +176,8 @@ fun MarkdownText( } }, update = { textView -> + textView.currentMessage = message + textView.onMarkdownTaskToggle = onMarkdownTaskToggle textView.setTextColor(textColorArgb) textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp) textView.maxLines = maxLines @@ -196,7 +234,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) @@ -235,6 +274,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) { @@ -464,6 +563,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