Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> =
remember(this) {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ internal val LocalMessageLongClickHandler = compositionLocalOf<(Int) -> Unit> {
internal val LocalHighlightSearchTerm = compositionLocalOf<String?> { null }
internal val LocalShowThreadButton = compositionLocalOf { true }
internal val LocalAvatarClickHandler = compositionLocalOf<(Int) -> Unit> { {} }
internal val LocalMarkdownTaskToggleHandler = compositionLocalOf<(Int, String) -> Unit> { { _, _ -> } }

private enum class MetadataLayoutMode {
CAPTION,
Expand Down
35 changes: 20 additions & 15 deletions app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
Expand Down
108 changes: 105 additions & 3 deletions app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading