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 7d68797814..19ad4051d2 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -155,6 +155,7 @@ import com.nextcloud.talk.ui.PinnedMessageView import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.chat.ChatView +import com.nextcloud.talk.ui.chat.ChatMessageCallbacks import com.nextcloud.talk.ui.chat.ChatViewCallbacks import com.nextcloud.talk.ui.chat.ChatViewState import com.nextcloud.talk.ui.dialog.DateTimeCompose @@ -625,17 +626,22 @@ class ChatActivity : onLoadMore = { loadMoreMessagesCompose() }, advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) }, updateRemoteLastReadMessageIfNeeded = { updateRemoteLastReadMessageIfNeeded() }, - onLongClick = { openMessageActionsDialog(it) }, - onSwipeReply = { handleSwipeToReply(it) }, - onFileClick = { downloadAndOpenFile(it) }, - onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) }, - onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) }, - onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) }, - onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) }, - onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) }, - onReactionLongClick = { messageId -> openReactionsDialog(messageId) }, - onOpenThreadClick = { messageId -> openThread(messageId.toLong()) }, - onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) } + onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) }, + messageCallbacks = ChatMessageCallbacks( + onLongClick = { openMessageActionsDialog(it) }, + onSwipeReply = { handleSwipeToReply(it) }, + onFileClick = { downloadAndOpenFile(it) }, + onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) }, + onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) }, + onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) }, + onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) }, + onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) }, + onReactionLongClick = { messageId -> openReactionsDialog(messageId) }, + onOpenThreadClick = { messageId -> openThread(messageId.toLong()) }, + onSystemMessageExpandClick = { messageId -> + chatViewModel.toggleSystemMessageCollapse(messageId) + } + ) ), listState = listState ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index 619f1dfa45..471ee11f7b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -25,6 +25,8 @@ import java.util.Date data class ChatMessage( var isGrouped: Boolean = false, + var isGroupedWithNext: Boolean = false, + var isOneToOneConversation: Boolean = false, var isFormerOneToOneConversation: Boolean = false, diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index 48c29624ff..a75ad0365e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -43,7 +43,14 @@ data class ChatMessageUi( val reactions: List = emptyList(), val isEdited: Boolean = false, val parentMessage: ChatMessageUi? = null, - val replyable: Boolean = false + val replyable: Boolean = false, + val isGrouped: Boolean = false, + val isGroupedWithNext: Boolean = false, + val isSilent: Boolean = false, + val isExpandableParent: Boolean = false, + val expandableChildrenAmount: Int = 0, + val isHiddenByCollapse: Boolean = false, + val isExpanded: Boolean = false ) data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean) @@ -125,7 +132,13 @@ fun ChatMessage.toUiModel( lastCommonReadMessageId = 0, parentMessage = null ), - replyable = replyable + replyable = replyable, + isGrouped = isGrouped, + isGroupedWithNext = isGroupedWithNext, + isSilent = silent, + isExpandableParent = expandableParent, + expandableChildrenAmount = expandableChildrenAmount, + isHiddenByCollapse = hiddenByCollapse ) private fun ChatMessage.normalizeMessageParameters(): Map> = diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 4d934fb845..142bb31fcf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -102,7 +102,9 @@ import kotlinx.coroutines.withTimeoutOrNull import retrofit2.HttpException import java.io.File import java.io.IOException +import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") @@ -331,6 +333,14 @@ class ChatViewModel @AssistedInject constructor( @Volatile private var oneOrMoreMessagesWereSent = false + private val expandedSystemMessageParents = MutableStateFlow>(emptySet()) + + fun toggleSystemMessageCollapse(parentMessageId: Int) { + expandedSystemMessageParents.update { current -> + if (parentMessageId in current) current - parentMessageId else current + parentMessageId + } + } + // ------------------------------ // UI State. This should be the only UI state. Add more val here and update via copy whenever necessary. // ------------------------------ @@ -622,7 +632,8 @@ class ChatViewModel @AssistedInject constructor( val messages: List, val lastCommonRead: Int, val parentMap: Map, - val conversationLastRead: Int + val conversationLastRead: Int, + val expandedParents: Set = emptySet() ) private data class ProcessedMessages(val items: List, val missingParentIds: List) @@ -635,11 +646,12 @@ class ChatViewModel @AssistedInject constructor( messagesFlow, getLastCommonReadFlow.onStart { emit(0) }, parentMessagesFlow, - conversationFlow.map { it.lastReadMessage } - ) { messages, lastCommonRead, parentMap, conversationLastRead -> - CombinedInput(messages, lastCommonRead, parentMap, conversationLastRead) + conversationFlow.map { it.lastReadMessage }, + expandedSystemMessageParents + ) { messages, lastCommonRead, parentMap, conversationLastRead, expandedParents -> + CombinedInput(messages, lastCommonRead, parentMap, conversationLastRead, expandedParents) } - .map { (messages, lastCommonRead, parentMap, conversationLastRead) -> + .map { (messages, lastCommonRead, parentMap, conversationLastRead, expandedParents) -> val messageMap: Map = messages.associateBy { it.jsonMessageId.toLong() } val combinedMap: Map = messageMap + parentMap @@ -649,6 +661,8 @@ class ChatViewModel @AssistedInject constructor( .distinct() val user = currentUserFlow.value + applyMessageGrouping(messages) + applySystemMessageGrouping(messages) val uiMessages = messages.map { message -> val parent: ChatMessage? = combinedMap[message.parentMessageId] message.toUiModel( @@ -659,7 +673,7 @@ class ChatViewModel @AssistedInject constructor( ) } - val items = buildChatItems(uiMessages, conversationLastRead) + val items = buildChatItems(uiMessages, conversationLastRead, expandedParents) ProcessedMessages(items = items, missingParentIds = missingParentIds) } .flowOn(Dispatchers.Default) @@ -685,8 +699,13 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Build chat items (pure) // ------------------------------ - private fun buildChatItems(uiMessages: List, lastReadMessage: Int): List { + private fun buildChatItems( + uiMessages: List, + lastReadMessage: Int, + expandedParents: Set = emptySet() + ): List { var lastDate: LocalDate? = null + var lastExpandableParentId: Int? = null return buildList { if (firstUnreadMessageId == null && lastReadMessage > 0) { @@ -700,6 +719,14 @@ class ChatViewModel @AssistedInject constructor( } for (uiMessage in uiMessages) { + if (uiMessage.isExpandableParent) { + lastExpandableParentId = uiMessage.id + } + + if (uiMessage.isHiddenByCollapse && lastExpandableParentId !in expandedParents) { + continue + } + val date = uiMessage.date if (date != lastDate) { @@ -711,11 +738,93 @@ class ChatViewModel @AssistedInject constructor( add(ChatItem.UnreadMessagesMarkerItem(date)) } - add(ChatItem.MessageItem(uiMessage)) + val adjustedMessage = if (uiMessage.isExpandableParent) { + uiMessage.copy(isExpanded = uiMessage.id in expandedParents) + } else { + uiMessage + } + add(ChatItem.MessageItem(adjustedMessage)) } }.asReversed() } + private fun applyMessageGrouping(messages: List) { + messages.forEachIndexed { index, message -> + message.isGrouped = index > 0 && shouldGroupMessage(message, messages[index - 1]) + message.isGroupedWithNext = index < messages.size - 1 && shouldGroupMessage(messages[index + 1], message) + } + } + + private fun applySystemMessageGrouping(messages: List) { + messages.forEach { message -> + message.expandableParent = false + message.lastItemOfExpandableGroup = 0 + message.expandableChildrenAmount = 0 + message.hiddenByCollapse = false + } + + messages.forEachIndexed { index, currentMessage -> + if (!currentMessage.isSystemMessage || index == 0) return@forEachIndexed + val previousMessage = messages[index - 1] + if (previousMessage.isSystemMessage && + previousMessage.systemMessageType == currentMessage.systemMessageType && + isSameDayMessages(previousMessage, currentMessage) + ) { + groupSystemMessages(previousMessage, currentMessage) + } + } + + messages.forEach { message -> + if (isChildOfExpandableGroup(message)) { + message.hiddenByCollapse = true + } + } + } + + private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) { + previousMessage.expandableParent = true + currentMessage.expandableParent = false + + if (currentMessage.lastItemOfExpandableGroup == 0) { + currentMessage.lastItemOfExpandableGroup = currentMessage.jsonMessageId + } + + previousMessage.lastItemOfExpandableGroup = currentMessage.lastItemOfExpandableGroup + previousMessage.expandableChildrenAmount = currentMessage.expandableChildrenAmount + 1 + } + + private fun isChildOfExpandableGroup(message: ChatMessage): Boolean = + message.isSystemMessage && !message.expandableParent && message.lastItemOfExpandableGroup != 0 + + private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean { + val date1 = Instant.ofEpochMilli(message1.timestamp * TIMESTAMP_TO_MILLIS) + .atZone(ZoneId.systemDefault()).toLocalDate() + val date2 = Instant.ofEpochMilli(message2.timestamp * TIMESTAMP_TO_MILLIS) + .atZone(ZoneId.systemDefault()).toLocalDate() + return date1 == date2 + } + + private fun shouldGroupMessage(current: ChatMessage, previous: ChatMessage): Boolean { + val sameMessageKind = current.isSystemMessage == previous.isSystemMessage + val notUnclassifiedBot = current.actorType != "bots" || current.actorId == "changelog" + val sameActor = current.isSystemMessage || + (current.actorType == previous.actorType && current.actorId == previous.actorId) + val currentDate = Instant.ofEpochMilli(current.timestamp * TIMESTAMP_TO_MILLIS) + .atZone(ZoneId.systemDefault()).toLocalDate() + val previousDate = Instant.ofEpochMilli(previous.timestamp * TIMESTAMP_TO_MILLIS) + .atZone(ZoneId.systemDefault()).toLocalDate() + val timeDifference = kotlin.math.abs(current.timestamp - previous.timestamp) + val neitherEdited = (current.lastEditTimestamp ?: 0L) == 0L || (previous.lastEditTimestamp ?: 0L) == 0L + + return sameMessageKind && + notUnclassifiedBot && + sameActor && + currentDate == previousDate && + current.actorId == previous.actorId && + timeDifference <= GROUPING_TIME_WINDOW_SECONDS && + neitherEdited + } + fun onMessageSent() { oneOrMoreMessagesWereSent = true } @@ -1775,6 +1884,8 @@ class ChatViewModel @AssistedInject constructor( private const val WEBSOCKET_CONNECT_TIMEOUT_MS = 3000L private const val WEBSOCKET_POLL_INTERVAL_MS = 50L private const val ROOM_REFRESH_DEBOUNCE_MS = 500L + private const val GROUPING_TIME_WINDOW_SECONDS = 300L + private const val TIMESTAMP_TO_MILLIS = 1000L } sealed class OutOfOfficeUIState { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessagePreviewHelpers.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessagePreviewHelpers.kt index 770a308050..a0f9b2a8d6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessagePreviewHelpers.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessagePreviewHelpers.kt @@ -113,3 +113,22 @@ internal fun createLongBaseMessage(content: MessageTypeContent?): ChatMessageUi content = content, reactions = previewReactions ) + +internal fun createMarkdownMessage(): ChatMessageUi = + ChatMessageUi( + id = 4, + message = "- Item 1\n- Item 2\n\n> A blockquote\n\n```kotlin\nval x = 1\n```", + plainMessage = "- Item 1\n- Item 2\n\n> A blockquote\n\n```kotlin\nval x = 1\n```", + renderMarkdown = true, + actorDisplayName = "Markdown Sender", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = MessageTypeContent.RegularText + ) 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 239ece847d..72069e8394 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 @@ -18,7 +18,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -35,12 +34,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue @@ -48,24 +44,20 @@ import androidx.compose.runtime.remember import androidx.compose.foundation.rememberScrollState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.Path import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -79,15 +71,14 @@ import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.TextMatchers import java.time.LocalDate private val regularTextSize = 16.sp private val timeTextSize = 12.sp private val authorTextSize = 12.sp -private const val QUOTE_SHAPE_OFFSET = 6 -private val quoteLineOffset = 8.dp -private val quoteLineStrokeWidth = 6.dp private const val LINE_SPACING = 1.2f +private const val SINGLE_EMOJI_SIZE_MULTIPLIER = 2.5f private const val HALF_OPACITY = 127 private const val MESSAGE_LENGTH_THRESHOLD = 25 private const val ANIMATED_BLINK = 500 @@ -101,6 +92,7 @@ internal val LocalReactionClickHandler = compositionLocalOf<(Int, String) -> Uni internal val LocalReactionLongClickHandler = compositionLocalOf<(Int) -> Unit> { {} } internal val LocalOpenThreadHandler = compositionLocalOf<(Int) -> Unit> { {} } internal val LocalQuotedMessageClickHandler = compositionLocalOf<(Int) -> Unit> { {} } +internal val LocalMessageLongClickHandler = compositionLocalOf<(Int) -> Unit> { {} } private enum class MetadataLayoutMode { CAPTION, @@ -201,20 +193,22 @@ fun MessageScaffold( showInlineMetadata = showInlineMetadata ) - val shape = remember(incoming) { + val shape = remember(incoming, uiMessage.isGrouped, uiMessage.isGroupedWithNext) { + val outerTop = if (uiMessage.isGrouped) bubbleRadiusSmall else bubbleRadiusBig + val outerBottom = if (uiMessage.isGroupedWithNext) bubbleRadiusSmall else bubbleRadiusBig if (incoming) { RoundedCornerShape( topStart = bubbleRadiusSmall, - topEnd = bubbleRadiusBig, - bottomEnd = bubbleRadiusBig, - bottomStart = bubbleRadiusBig + topEnd = outerTop, + bottomEnd = outerBottom, + bottomStart = outerBottom ) } else { RoundedCornerShape( - topStart = bubbleRadiusBig, + topStart = outerTop, topEnd = bubbleRadiusSmall, - bottomEnd = bubbleRadiusBig, - bottomStart = bubbleRadiusBig + bottomEnd = outerBottom, + bottomStart = outerBottom ) } } @@ -246,7 +240,7 @@ fun MessageScaffold( @Composable private fun RowScope.MessageLeadingDecoration(uiMessage: ChatMessageUi, isOneToOneConversation: Boolean) { - if (uiMessage.incoming && isOneToOneConversation) { + if (uiMessage.incoming && isOneToOneConversation && !uiMessage.isGrouped) { val errorPlaceholderImage: Int = R.drawable.account_circle_96dp val avatarContext = LocalContext.current val loadedImage = remember(uiMessage.avatarUrl) { @@ -260,6 +254,8 @@ private fun RowScope.MessageLeadingDecoration(uiMessage: ChatMessageUi, isOneToO .align(Alignment.Top) .padding(end = 8.dp) ) + } else if (uiMessage.incoming && isOneToOneConversation) { + Spacer(Modifier.width(48.dp)) } else if (uiMessage.incoming) { Spacer(Modifier.width(8.dp)) } @@ -286,7 +282,7 @@ private fun MessageBubbleWithReactions( .widthIn(60.dp, 280.dp) Column(horizontalAlignment = if (incoming) Alignment.Start else Alignment.End) { - if (incoming && isOneToOneConversation) { + if (incoming && isOneToOneConversation && !uiMessage.isGrouped) { Text( text = uiMessage.actorDisplayName, fontSize = authorTextSize, @@ -585,6 +581,16 @@ private fun MessageMetadata(uiMessage: ChatMessageUi, color: Color = colorScheme color = color ) } + if (uiMessage.isSilent) { + Icon( + painter = painterResource(R.drawable.ic_baseline_notifications_off_24), + contentDescription = stringResource(R.string.silent_message_hint), + modifier = Modifier + .padding(end = 4.dp) + .size(16.dp), + tint = color + ) + } TimeDisplay(uiMessage, color) if (!uiMessage.incoming) { ReadStatus(uiMessage, color) @@ -636,37 +642,54 @@ private fun ColumnScope.CaptionWithMetadata( } } +@Suppress("LongMethod") @Composable fun CommonMessageQuote(message: ChatMessageUi) { - val quoteLineColor = colorResource(R.color.textColorMaxContrast) - val quoteBackgroundColor = colorResource(R.color.reply_background) + val lineColor = if (!message.incoming) { + colorScheme.primary + } else { + colorScheme.onSurfaceVariant + } + val bgColor = colorResource(R.color.reply_background) val onQuotedMessageClick = LocalQuotedMessageClickHandler.current Row( modifier = Modifier - .combinedClickable( - onClick = { onQuotedMessageClick(message.id) } - ) + .padding(vertical = 4.dp) + .combinedClickable(onClick = { onQuotedMessageClick(message.id) }) .fillMaxWidth() - .background( - color = quoteBackgroundColor, - shape = RoundedCornerShape(8.dp) - ) - .drawWithCache { - val lineOffset = quoteLineOffset.toPx() - val lineStrokeWidth = quoteLineStrokeWidth.toPx() - onDrawWithContent { - drawLine( - color = quoteLineColor, - start = Offset(lineOffset, this.size.height / QUOTE_SHAPE_OFFSET), - end = Offset(lineOffset, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), - strokeWidth = lineStrokeWidth, - cap = StrokeCap.Round - ) - - drawContent() - } + .drawBehind { + val barWidth = 4.dp.toPx() + val r = 8.dp.toPx() + drawPath( + Path().apply { + addRoundRect( + RoundRect( + rect = Rect(0f, 0f, barWidth, size.height), + topLeft = CornerRadius(r), + topRight = CornerRadius.Zero, + bottomRight = CornerRadius.Zero, + bottomLeft = CornerRadius(r) + ) + ) + }, + lineColor + ) + drawPath( + Path().apply { + addRoundRect( + RoundRect( + rect = Rect(barWidth, 0f, size.width, size.height), + topLeft = CornerRadius.Zero, + topRight = CornerRadius(r), + bottomRight = CornerRadius(r), + bottomLeft = CornerRadius.Zero + ) + ) + }, + bgColor + ) } - .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp) + .padding(start = 12.dp, top = 4.dp, end = 4.dp, bottom = 4.dp) ) { Column { Text( @@ -675,8 +698,9 @@ fun CommonMessageQuote(message: ChatMessageUi) { color = colorResource(R.color.no_emphasis_text) ) EnrichedText( - message, - Modifier.padding(start = 10.dp) + message = message, + modifier = Modifier.padding(end = 4.dp), + maxLines = 4 ) } } @@ -762,128 +786,44 @@ fun ThreadTitle( } } -@Composable -fun EnrichedText(message: ChatMessageUi, modifier: Modifier) { - MentionEnrichedText( - message = message, - modifier = modifier, - textStyle = TextStyle( - fontSize = regularTextSize, - color = colorScheme.onSurface, - lineHeight = regularTextSize * LINE_SPACING - ) - ) -} - -fun AnnotatedString.Builder.appendMarkdownWithLinks(text: String) { - val regex = Regex( - pattern = """(\*\*.*?\*\*|\*.*?\*|`.*?`|\[.*?\]\(.*?\)|https?://\S+)""" - ) - - var lastIndex = 0 - - for (match in regex.findAll(text)) { - val range = match.range - - // Append normal text before match - if (lastIndex < range.first) { - append(text.substring(lastIndex, range.first)) - } - - val token = match.value - - when { - // **bold** - token.startsWith("**") -> { - val content = token.removeSurrounding("**") - val start = length - append(content) - addStyle( - SpanStyle(fontWeight = FontWeight.Bold), - start, - length - ) - } - - // *italic* - token.startsWith("*") -> { - val content = token.removeSurrounding("*") - val start = length - append(content) - addStyle( - SpanStyle(fontStyle = FontStyle.Italic), - start, - length - ) - } - - // `code` - token.startsWith("`") -> { - val content = token.removeSurrounding("`") - val start = length - append(content) - addStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = Color.LightGray - ), - start, - length - ) - } - - // [text](url) - token.startsWith("[") -> { - val textPart = token.substringAfter("[").substringBefore("]") - val url = token.substringAfter("(").substringBefore(")") - - val start = length - append(textPart) - - addStyle( - SpanStyle( - color = Color.Blue, - textDecoration = TextDecoration.Underline - ), - start, - length - ) - - addLink( - LinkAnnotation.Url(url), - start, - length - ) - } - - // plain URL - token.startsWith("http") -> { - val start = length - append(token) - - addStyle( - SpanStyle( - color = Color.Blue, - textDecoration = TextDecoration.Underline - ), - start, - length - ) - - addLink( - LinkAnnotation.Url(token), - start, - length - ) - } +internal fun resolveMarkdownSource(message: ChatMessageUi): String { + var result = message.plainMessage + for ((key, params) in message.messageParameters) { + val token = "{$key}" + if (result.contains(token)) { + val name = params["name"].orEmpty() + val replacement = if (params["type"] in mentionChipTypes) "@$name" else name + result = result.replace(token, replacement) } - - lastIndex = range.last + 1 } + return result +} - // Append remaining text - if (lastIndex < text.length) { - append(text.substring(lastIndex)) +@Composable +fun EnrichedText(message: ChatMessageUi, modifier: Modifier, maxLines: Int = Int.MAX_VALUE) { + val isSingleEmoji = message.messageParameters.isEmpty() && + TextMatchers.isMessageWithSingleEmoticonOnly(message.plainMessage) + val fontSize = if (isSingleEmoji) regularTextSize * SINGLE_EMOJI_SIZE_MULTIPLIER else regularTextSize + + if (message.renderMarkdown) { + MarkdownText( + message = message, + textColor = colorScheme.onSurface, + modifier = modifier, + maxLines = maxLines, + textSizeSp = fontSize.value + ) + } else { + MentionEnrichedText( + message = message, + modifier = modifier, + textStyle = TextStyle( + fontSize = fontSize, + color = colorScheme.onSurface, + lineHeight = fontSize * LINE_SPACING + ), + maxLines = maxLines + ) } } @@ -917,8 +857,7 @@ private fun Modifier.withCustomAnimation(incoming: Boolean, shape: RoundedCorner ) @Composable private fun MessageScaffoldIncomingPreview() { - val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() - MaterialTheme(colorScheme = colorScheme) { + PreviewContainer { val uiMessage = ChatMessageUi( id = 1, message = "Hello! How are you?", @@ -952,8 +891,7 @@ private fun MessageScaffoldIncomingPreview() { ) @Composable private fun MessageScaffoldIncomingLongPreview() { - val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() - MaterialTheme(colorScheme = colorScheme) { + PreviewContainer { val uiMessage = ChatMessageUi( id = 1, message = "Hello! How are youuuuuuuuuuuuuuuuuuuuuuuuuuuuuu?", @@ -987,8 +925,7 @@ private fun MessageScaffoldIncomingLongPreview() { ) @Composable private fun MessageScaffoldOutgoingPreview() { - val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() - MaterialTheme(colorScheme = colorScheme) { + PreviewContainer { val uiMessage = ChatMessageUi( id = 2, message = "I'm doing great, thanks!", @@ -1017,7 +954,7 @@ private fun MessageScaffoldOutgoingPreview() { @Preview(showBackground = true, name = "Quoted Message") @Composable private fun CommonMessageQuotePreview() { - MaterialTheme(colorScheme = lightColorScheme()) { + PreviewContainer { val uiMessage = ChatMessageUi( id = 3, message = "This is a quoted message", 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 e766b6ee17..c7d639501f 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 @@ -54,6 +54,7 @@ data class ChatMessageContext( val hasChatPermission: Boolean = true ) +@Suppress("Detekt.LongParameterList") class ChatMessageCallbacks( val onLongClick: ((Int) -> Unit?)? = null, val onSwipeReply: ((Int) -> Unit)? = null, @@ -65,9 +66,11 @@ class ChatMessageCallbacks( val onReactionClick: (Int, String) -> Unit = { _, _ -> }, val onReactionLongClick: (Int) -> Unit = {}, val onOpenThreadClick: (Int) -> Unit = {}, - val onQuotedMessageClick: (Int) -> Unit = {} + val onQuotedMessageClick: (Int) -> Unit = {}, + val onSystemMessageExpandClick: (Int) -> Unit = {} ) +@Suppress("Detekt.LongMethod", "Detekt.CyclomaticComplexMethod") @Composable fun ChatMessageView( message: ChatMessageUi, @@ -92,6 +95,7 @@ fun ChatMessageView( } CompositionLocalProvider( + LocalMessageLongClickHandler provides { id -> callbacks.onLongClick?.invoke(id) ?: Unit }, LocalReactionClickHandler provides callbacks.onReactionClick, LocalReactionLongClickHandler provides callbacks.onReactionLongClick, LocalOpenThreadHandler provides callbacks.onOpenThreadClick, @@ -106,7 +110,13 @@ fun ChatMessageView( .combinedClickable( interactionSource = interactionSource, indication = ripple(), - onClick = { callbacks.onLongClick?.invoke(message.id) }, + 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) } ) 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 db8c829e9b..65f1a0fc67 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 @@ -96,17 +96,8 @@ class ChatViewCallbacks( val onLoadMore: (() -> Unit?)? = null, val advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)? = null, val updateRemoteLastReadMessageIfNeeded: (() -> Unit?)? = null, - val onLongClick: ((Int) -> Unit?)? = null, - val onFileClick: (Int) -> Unit = {}, - val onPollClick: (String, String) -> Unit = { _, _ -> }, - val onVoicePlayPauseClick: (Int) -> Unit = {}, - val onVoiceSeek: (Int, Int) -> Unit = { _, _ -> }, - val onVoiceSpeedClick: (Int) -> Unit = {}, - val onReactionClick: (Int, String) -> Unit = { _, _ -> }, - val onReactionLongClick: (Int) -> Unit = {}, - val onOpenThreadClick: (Int) -> Unit = {}, val onLoadQuotedMessageClick: (Int) -> Unit = {}, - val onSwipeReply: ((Int) -> Unit)? = null + val messageCallbacks: ChatMessageCallbacks = ChatMessageCallbacks() ) @Suppress("Detekt.LongMethod", "Detekt.ComplexMethod") @@ -292,7 +283,7 @@ fun ChatView( LazyColumn( state = listState, reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), contentPadding = PaddingValues(bottom = 20.dp), modifier = Modifier .padding(start = 12.dp, end = 12.dp) @@ -304,38 +295,49 @@ fun ChatView( ) { chatItem -> when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { - ChatMessageView( - message = chatItem.uiMessage, - highlightTriggerKey = quoteHighlightEvent - ?.takeIf { it.messageId == chatItem.uiMessage.id } - ?.nonce, - context = ChatMessageContext( - isOneToOneConversation = state.isOneToOneConversation, - conversationThreadId = state.conversationThreadId, - hasChatPermission = state.hasChatPermission - ), - callbacks = ChatMessageCallbacks( - onLongClick = callbacks.onLongClick, - onSwipeReply = callbacks.onSwipeReply, - onFileClick = callbacks.onFileClick, - onPollClick = callbacks.onPollClick, - onVoicePlayPauseClick = callbacks.onVoicePlayPauseClick, - onVoiceSeek = callbacks.onVoiceSeek, - onVoiceSpeedClick = callbacks.onVoiceSpeedClick, - onReactionClick = callbacks.onReactionClick, - onReactionLongClick = callbacks.onReactionLongClick, - onOpenThreadClick = callbacks.onOpenThreadClick, - onQuotedMessageClick = handleQuotedMessageClick + Box( + modifier = Modifier.padding( + top = if (!chatItem.uiMessage.isGrouped) 4.dp else 0.dp ) - ) + ) { + ChatMessageView( + message = chatItem.uiMessage, + highlightTriggerKey = quoteHighlightEvent + ?.takeIf { it.messageId == chatItem.uiMessage.id } + ?.nonce, + context = ChatMessageContext( + isOneToOneConversation = state.isOneToOneConversation, + conversationThreadId = state.conversationThreadId, + hasChatPermission = state.hasChatPermission + ), + callbacks = ChatMessageCallbacks( + onLongClick = callbacks.messageCallbacks.onLongClick, + onSwipeReply = callbacks.messageCallbacks.onSwipeReply, + onFileClick = callbacks.messageCallbacks.onFileClick, + onPollClick = callbacks.messageCallbacks.onPollClick, + onVoicePlayPauseClick = callbacks.messageCallbacks.onVoicePlayPauseClick, + onVoiceSeek = callbacks.messageCallbacks.onVoiceSeek, + onVoiceSpeedClick = callbacks.messageCallbacks.onVoiceSpeedClick, + onReactionClick = callbacks.messageCallbacks.onReactionClick, + onReactionLongClick = callbacks.messageCallbacks.onReactionLongClick, + onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, + onQuotedMessageClick = handleQuotedMessageClick, + onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick + ) + ) + } } is ChatViewModel.ChatItem.DateHeaderItem -> { - DateHeader(chatItem.date) + Box(modifier = Modifier.padding(top = 6.dp)) { + DateHeader(chatItem.date) + } } is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> { - UnreadMessagesMarker() + Box(modifier = Modifier.padding(top = 6.dp)) { + UnreadMessagesMarker() + } } } } 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 new file mode 100644 index 0000000000..3d2faaae12 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt @@ -0,0 +1,438 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ReplacementSpan +import android.text.util.Linkify +import android.util.TypedValue +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import coil.imageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.events.UserMentionClickEvent +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.message.MessageUtils +import org.greenrobot.eventbus.EventBus +import java.lang.ref.WeakReference +import kotlin.math.roundToInt + +private val mentionParamTypes = setOf("user", "guest", "call", "user-group", "email", "circle") +private const val TEXT_SIZE_SP = 16f +private const val LINE_HEIGHT_MULTIPLIER = 1.4f +private const val AVATAR_SIZE_DP = 20f +private const val AVATAR_GAP_DP = 4f +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 + +@Suppress("LongMethod") +@Composable +fun MarkdownText( + message: ChatMessageUi, + textColor: Color, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + textSizeSp: Float = TEXT_SIZE_SP +) { + val context = LocalContext.current + val textColorArgb = textColor.toArgb() + val themedColors = LocalViewThemeUtils.current.getColorScheme(context) + val linkColorArgb = MaterialTheme.colorScheme.primary.toArgb() + val chipBgColor = MaterialTheme.colorScheme.surfaceContainerHighest.toArgb() + val chipTextColor = MaterialTheme.colorScheme.onSurface.toArgb() + val selfChipBgColor = themedColors.primary.toArgb() + val selfChipTextColor = themedColors.onPrimary.toArgb() + val density = LocalDensity.current + val chipStartPaddingPx = with(density) { CHIP_START_PADDING_DP.dp.toPx() } + val chipEndPaddingPx = with(density) { CHIP_END_PADDING_DP.dp.toPx() } + val chipVerticalPaddingPx = with(density) { CHIP_VERTICAL_PADDING_DP.dp.toPx() } + val chipCornerRadiusPx = with(density) { CHIP_CORNER_RADIUS_DP.dp.toPx() } + val avatarSizePx = with(density) { AVATAR_SIZE_DP.dp.toPx() } + val avatarGapPx = with(density) { AVATAR_GAP_DP.dp.toPx() } + val messageId = message.id + val onMessageLongClick = LocalMessageLongClickHandler.current + val onLongClickState = rememberUpdatedState(onMessageLongClick) + + if (LocalInspectionMode.current) { + Text( + text = message.plainMessage, + color = textColor, + modifier = modifier, + maxLines = maxLines, + fontSize = textSizeSp.sp + ) + } else { + AndroidView( + modifier = modifier, + factory = { ctx -> + val gestureDetector = GestureDetector( + ctx, + object : GestureDetector.SimpleOnGestureListener() { + override fun onLongPress(e: MotionEvent) { + onLongClickState.value(messageId) + } + } + ) + val longPressListener = View.OnTouchListener { view, event -> + if (event.action == MotionEvent.ACTION_UP) { + view.performClick() + } + gestureDetector.onTouchEvent(event) + false + } + LongPressTextView(ctx).apply { + isFocusable = false + isFocusableInTouchMode = false + tag = longPressListener + } + }, + update = { textView -> + textView.setTextColor(textColorArgb) + textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp) + textView.maxLines = maxLines + textView.setLineSpacing(0f, textView.textSize * LINE_HEIGHT_MULTIPLIER / textView.paint.fontSpacing) + val markwon = MessageUtils.buildMarkwon(context, textColorArgb) + markwon.setMarkdown(textView, resolveNonMentionParams(message)) + val ssb = SpannableStringBuilder(textView.text) + val hasClickableChips = applyMentionChips( + spannable = ssb, + message = message, + context = context, + textView = textView, + chipBgColor = chipBgColor, + chipTextColor = chipTextColor, + selfChipBgColor = selfChipBgColor, + selfChipTextColor = selfChipTextColor, + textSizePx = textView.textSize, + startPaddingPx = chipStartPaddingPx, + endPaddingPx = chipEndPaddingPx, + verticalPaddingPx = chipVerticalPaddingPx, + cornerRadiusPx = chipCornerRadiusPx, + avatarSizePx = avatarSizePx, + avatarGapPx = avatarGapPx + ) + val hasLinks = Linkify.addLinks(ssb, Linkify.WEB_URLS) + resolveFileParams(ssb, message) + textView.text = ssb + textView.setLinkTextColor(linkColorArgb) + val needsMovementMethod = hasClickableChips || hasLinks + if (needsMovementMethod) { + textView.movementMethod = LinkMovementMethod.getInstance() + textView.setOnTouchListener(textView.tag as? View.OnTouchListener) + } else { + textView.movementMethod = null + textView.setOnTouchListener(null) + } + } + ) + } +} + +private fun resolveNonMentionParams(message: ChatMessageUi): String { + var result = message.plainMessage + for ((key, params) in message.messageParameters) { + if (params["type"] in mentionParamTypes) continue + if (params["type"] == "file") continue + val token = "{$key}" + if (result.contains(token)) { + result = result.replace(token, params["name"].orEmpty()) + } + } + return result +} + +private fun resolveFileParams(spannable: SpannableStringBuilder, message: ChatMessageUi) { + for ((key, params) in message.messageParameters) { + if (params["type"] != "file") continue + val token = "{$key}" + val name = params["name"].orEmpty() + var searchFrom = 0 + while (true) { + val start = spannable.indexOf(token, searchFrom) + if (start < 0) break + spannable.replace(start, start + token.length, name) + searchFrom = start + name.length + } + } +} + +@Suppress("LongParameterList", "CyclomaticComplexMethod") +private fun applyMentionChips( + spannable: SpannableStringBuilder, + message: ChatMessageUi, + context: Context, + textView: TextView, + chipBgColor: Int, + chipTextColor: Int, + selfChipBgColor: Int, + selfChipTextColor: Int, + textSizePx: Float, + startPaddingPx: Float, + endPaddingPx: Float, + verticalPaddingPx: Float, + cornerRadiusPx: Float, + avatarSizePx: Float, + avatarGapPx: Float +): Boolean { + val text = spannable.toString() + var hasClickableChips = false + for ((key, params) in message.messageParameters) { + val type = params["type"] ?: continue + if (type !in mentionParamTypes) continue + val name = params["name"].orEmpty() + val rawId = params["id"].orEmpty() + val server = params["server"] + val isFederated = !server.isNullOrEmpty() + val isSelfMention = rawId == message.activeUserId + val isClickable = type == "user" && !isSelfMention && !isFederated + val mentionId = if (isFederated) "$rawId@$server" else rawId + val bgColor = if (isSelfMention) selfChipBgColor else chipBgColor + val fgColor = if (isSelfMention) selfChipTextColor else chipTextColor + val avatarUrl = resolveMentionAvatarUrl(message, rawId, name, type, mentionId, isFederated) + val fallbackIconRes = resolveFallbackIcon(type, name, isSelfMention) + val fallbackDrawable = ContextCompat.getDrawable(context, fallbackIconRes)?.mutate() ?: continue + val token = "{$key}" + var searchFrom = 0 + while (true) { + val start = text.indexOf(token, searchFrom) + if (start < 0) break + val end = start + token.length + val chipSpan = MentionChipSpan( + label = name, + fallbackDrawable = fallbackDrawable, + avatarUrl = avatarUrl, + backgroundColor = bgColor, + labelColor = fgColor, + textSizePx = textSizePx, + startPaddingPx = startPaddingPx, + endPaddingPx = endPaddingPx, + verticalPaddingPx = verticalPaddingPx, + cornerRadiusPx = cornerRadiusPx, + avatarSizePx = avatarSizePx, + avatarGapPx = avatarGapPx + ) + spannable.setSpan(chipSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + chipSpan.startLoading(context, textView) + if (isClickable) { + spannable.setSpan( + MentionClickSpan(mentionId), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + hasClickableChips = true + } + searchFrom = end + } + } + return hasClickableChips +} + +@Suppress("LongParameterList") +private fun resolveMentionAvatarUrl( + message: ChatMessageUi, + rawId: String, + name: String, + type: String, + mentionId: String, + isFederated: Boolean +): String? { + val baseUrl = message.activeUserBaseUrl ?: return null + return when { + isFederated && !message.roomToken.isNullOrEmpty() -> ApiUtils.getUrlForFederatedAvatar( + baseUrl = baseUrl, + token = message.roomToken, + cloudId = mentionId, + darkTheme = 0, + requestBigSize = false + ) + type == "guest" || type == "email" -> ApiUtils.getUrlForGuestAvatar( + baseUrl = baseUrl, + name = name, + requestBigSize = true + ) + type == "call" || type == "user-group" || type == "circle" -> null + rawId.isNotEmpty() -> ApiUtils.getUrlForAvatar(baseUrl, rawId, false, false) + else -> null + } +} + +private fun resolveFallbackIcon(type: String, name: String, isSelfMention: Boolean): Int = + when { + type == "call" && name.startsWith("+") -> R.drawable.icon_circular_phone + type == "call" -> R.drawable.ic_circular_group_mentions + type == "user-group" -> R.drawable.ic_circular_group_mentions + type == "circle" -> R.drawable.icon_circular_team + isSelfMention -> R.drawable.mention_chip + else -> R.drawable.accent_circle + } + +private class MentionClickSpan(private val mentionId: String) : ClickableSpan() { + override fun onClick(widget: View) { + EventBus.getDefault().post(UserMentionClickEvent(mentionId)) + } + + override fun updateDrawState(ds: TextPaint) { + // MentionChipSpan handles all visual styling; suppress default link styling + } +} + +@Suppress("LongParameterList") +private class MentionChipSpan( + private val label: String, + private val fallbackDrawable: Drawable, + private val avatarUrl: String?, + private val backgroundColor: Int, + private val labelColor: Int, + private val textSizePx: Float, + private val startPaddingPx: Float, + private val endPaddingPx: Float, + private val verticalPaddingPx: Float, + private val cornerRadiusPx: Float, + private val avatarSizePx: Float, + private val avatarGapPx: Float +) : ReplacementSpan() { + + private var avatarDrawable: Drawable? = null + private var targetView: WeakReference? = null + + fun startLoading(context: Context, view: View) { + targetView = WeakReference(view) + if (avatarUrl == null) return + val request = ImageRequest.Builder(context) + .data(avatarUrl) + .size(avatarSizePx.roundToInt()) + .transformations(CircleCropTransformation()) + .target { drawable -> + avatarDrawable = drawable + targetView?.get()?.invalidate() + } + .build() + context.imageLoader.enqueue(request) + } + + override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + val p = Paint(paint).apply { textSize = textSizePx } + if (fm != null) { + val metrics = p.fontMetricsInt + // The chip is anchored so its centered label lands on the text baseline. + // chipCenterY = baseline + (ascent + descent) / 2 + // chipTop = chipCenterY - chipHeight / 2 → offset from baseline = midOffset - halfChip + // chipBottom = chipCenterY + chipHeight / 2 → offset from baseline = midOffset + halfChip + val halfChip = (avatarSizePx + 2 * verticalPaddingPx) / 2f + val midOffset = (metrics.ascent + metrics.descent) / 2f + fm.ascent = minOf(metrics.ascent, (midOffset - halfChip).toInt()) + fm.descent = maxOf(metrics.descent, (midOffset + halfChip).roundToInt()) + fm.top = fm.ascent + fm.bottom = fm.descent + } + val textWidth = p.measureText(label) + return (startPaddingPx + avatarSizePx + avatarGapPx + textWidth + endPaddingPx).roundToInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textPaint = Paint(paint).apply { + color = labelColor + textSize = textSizePx + isAntiAlias = true + } + val width = getSize(paint, text, start, end, null).toFloat() + val chipHeight = avatarSizePx + 2 * verticalPaddingPx + // Anchor chip so its centered label text sits exactly on the surrounding baseline (y). + val chipCenterY = y + (textPaint.ascent() + textPaint.descent()) / 2f + val chipTop = chipCenterY - chipHeight / 2f + val chipBottom = chipTop + chipHeight + + val backgroundPaint = Paint().apply { + color = backgroundColor + style = Paint.Style.FILL + isAntiAlias = true + } + canvas.drawRoundRect(RectF(x, chipTop, x + width, chipBottom), cornerRadiusPx, cornerRadiusPx, backgroundPaint) + + val drawable = avatarDrawable ?: fallbackDrawable + val avatarLeft = x + startPaddingPx + val avatarTop = chipTop + verticalPaddingPx + drawable.setBounds( + avatarLeft.roundToInt(), + avatarTop.roundToInt(), + (avatarLeft + avatarSizePx).roundToInt(), + (avatarTop + avatarSizePx).roundToInt() + ) + drawable.draw(canvas) + + // textBaseline = chipCenterY - (ascent + descent) / 2 = y (by construction above) + canvas.drawText(label, avatarLeft + avatarSizePx + avatarGapPx, y.toFloat(), textPaint) + } +} + +private class LongPressTextView(context: Context) : AppCompatTextView(context) { + override fun performClick(): Boolean { + super.performClick() + return true + } +} + +private fun markdownTextPreviewMessage(): ChatMessageUi = + createMarkdownMessage().copy( + plainMessage = "Hello {user1}! Here is the list:\n- Item 1\n- Item 2\n\n> A blockquote", + messageParameters = mapOf("user1" to mapOf("type" to "user", "name" to "alice", "id" to "alice")) + ) + +@ChatMessagePreviews +@Composable +private fun MarkdownTextPreview() { + PreviewContainer { + MarkdownText( + message = markdownTextPreviewMessage(), + textColor = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt index b9e11debe8..89c067cf6e 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt @@ -59,7 +59,7 @@ import com.nextcloud.talk.utils.ApiUtils import org.greenrobot.eventbus.EventBus private val messageTokenRegex = - Regex("""(\{[^{}]+\}|\*\*.*?\*\*|\*.*?\*|`.*?`|\[.*?]\(.*?\)|https?://\S+)""") + Regex("""(\{[^{}]+\}|\*\*.*?\*\*|\*.*?\*|~~.*?~~|`.*?`|\[.*?]\(.*?\)|https?://\S+)""") private val mentionParameterTypes = setOf("user", "guest", "call", "user-group", "email", "circle") @@ -90,7 +90,12 @@ private data class MentionChipModel( private data class MentionRichText(val annotated: AnnotatedString, val inlineContent: Map) @Composable -fun MentionEnrichedText(message: ChatMessageUi, modifier: Modifier = Modifier, textStyle: TextStyle) { +fun MentionEnrichedText( + message: ChatMessageUi, + modifier: Modifier = Modifier, + textStyle: TextStyle, + maxLines: Int = Int.MAX_VALUE +) { var isMultilineLayout by remember(message.id, message.message) { mutableStateOf(message.message.contains("\n") || message.message.contains("\r")) } @@ -116,6 +121,8 @@ fun MentionEnrichedText(message: ChatMessageUi, modifier: Modifier = Modifier, t text = richText.annotated, inlineContent = richText.inlineContent, style = resolvedTextStyle, + maxLines = maxLines, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, onTextLayout = { textLayoutResult -> val isCurrentlyMultiline = textLayoutResult.lineCount > 1 if (isMultilineLayout != isCurrentlyMultiline) { @@ -167,6 +174,12 @@ private fun buildMentionRichText( token.startsWith( "*" ) -> appendStyledToken(token.removeSurrounding("*"), SpanStyle(fontStyle = FontStyle.Italic)) + token.startsWith( + "~~" + ) -> appendStyledToken( + token.removeSurrounding("~~"), + SpanStyle(textDecoration = TextDecoration.LineThrough) + ) token.startsWith("`") -> { appendStyledToken( token.removeSurrounding("`"), @@ -288,24 +301,13 @@ private fun MentionChip(mention: MentionChipModel, textStyle: TextStyle, isMulti verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - if (mention.avatarUrl != null) { - val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } - AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) - } else { - Icon( - painter = painterResource(fallbackIcon), - contentDescription = null, - modifier = Modifier.size(mentionIconSize), - tint = Color.Unspecified - ) - } + MentionChipIcon(mention = mention, fallbackIcon = fallbackIcon) Text( text = mention.name, color = textColor, maxLines = 1, - modifier = Modifier - .padding(end = 3.dp), + modifier = Modifier.padding(end = 3.dp), style = textStyle.copy( color = textColor, fontSize = chipTextSize, @@ -317,6 +319,22 @@ private fun MentionChip(mention: MentionChipModel, textStyle: TextStyle, isMulti } } +@Composable +private fun MentionChipIcon(mention: MentionChipModel, fallbackIcon: Int) { + if (mention.avatarUrl != null) { + val context = LocalContext.current + val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } + AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) + } else { + Icon( + painter = painterResource(fallbackIcon), + contentDescription = null, + modifier = Modifier.size(mentionIconSize), + tint = Color.Unspecified + ) + } +} + private fun String.toMentionChipModel(message: ChatMessageUi): MentionChipModel? { val parameter = message.messageParameters[removePrefix("{").removeSuffix("}")] val type = parameter?.get("type") diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt index ce50553b4e..5421691c17 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -9,17 +9,23 @@ package com.nextcloud.talk.ui.chat import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.utils.DateUtils @@ -31,14 +37,18 @@ fun SystemMessage(message: ChatMessageUi) { Column(horizontalAlignment = Alignment.CenterHorizontally) { val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) Box(modifier = Modifier.fillMaxWidth()) { - Text( - message.message, - fontSize = AUTHOR_TEXT_SIZE.sp, - color = colorScheme.onSurface, - modifier = Modifier - .padding(8.dp) - .align(Alignment.Center) - ) + if (message.isExpandableParent) { + ExpandableSystemMessage(message = message) + } else { + Text( + message.message, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) + .align(Alignment.Center) + ) + } Text( timeString, fontSize = TIME_TEXT_SIZE.sp, @@ -51,3 +61,42 @@ fun SystemMessage(message: ChatMessageUi) { } } } + +@Composable +private fun ExpandableSystemMessage(message: ChatMessageUi) { + val chevronRes = if (message.isExpanded) R.drawable.ic_keyboard_arrow_up else R.drawable.ic_keyboard_arrow_down + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(chevronRes), + contentDescription = null, + modifier = Modifier + .padding(end = 4.dp) + .size(16.dp), + tint = colorScheme.onSurfaceVariant + ) + Text( + message.message, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = colorScheme.onSurface + ) + } + if (!message.isExpanded) { + Text( + pluralStringResource( + R.plurals.see_similar_system_messages, + message.expandableChildrenAmount, + message.expandableChildrenAmount + ), + fontSize = (AUTHOR_TEXT_SIZE - 1).sp, + color = colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt index 2acd3f44a7..c4e7ba40ef 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -54,3 +54,11 @@ private fun TextMessageOutgoingPreview() { ) } } + +@ChatMessagePreviews +@Composable +private fun TextMessageMarkdownPreview() { + PreviewContainer { + TextMessage(uiMessage = createMarkdownMessage()) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index ad0442cc18..5f1a513bd2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -21,6 +21,7 @@ import io.noties.markwon.ext.strikethrough.StrikethroughPlugin import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.ext.tasklist.TaskListDrawable import io.noties.markwon.ext.tasklist.TaskListPlugin +import java.lang.ref.WeakReference class MessageUtils(val context: Context) { @@ -48,26 +49,42 @@ class MessageUtils(val context: Context) { return SpannableString(result) } - fun getRenderedMarkdownText(context: Context, markdown: String, textColor: Int): Spanned { - val drawable = TaskListDrawable(textColor, textColor, context.getColor(R.color.bg_default)) - val markwon = Markwon.builder(context).usePlugin(object : AbstractMarkwonPlugin() { - override fun configureTheme(builder: MarkwonTheme.Builder) { - builder.isLinkUnderlined(true).headingBreakHeight(0) - } - - override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { - builder.linkResolver { view: View?, link: String? -> - Log.i(TAG, "Link action not implemented $view / $link") - } - } - }) - .usePlugin(TaskListPlugin.create(drawable)) - .usePlugin(TablePlugin.create { _ -> }) - .usePlugin(StrikethroughPlugin.create()).build() - return markwon.toMarkdown(markdown) - } + fun getRenderedMarkdownText(context: Context, markdown: String, textColor: Int): Spanned = + buildMarkwon(context, textColor).toMarkdown(markdown) companion object { private const val TAG = "MessageUtils" + + private var cachedMarkwon: Markwon? = null + private var cachedContextRef: WeakReference? = null + private var cachedTextColor: Int = 0 + + fun buildMarkwon(context: Context, textColor: Int): Markwon { + val cached = cachedMarkwon + if (cached != null && cachedContextRef?.get() === context && cachedTextColor == textColor) { + return cached + } + val drawable = TaskListDrawable(textColor, textColor, context.getColor(R.color.bg_default)) + val markwon = Markwon.builder(context) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureTheme(builder: MarkwonTheme.Builder) { + builder.isLinkUnderlined(true).headingBreakHeight(0) + } + + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + builder.linkResolver { view: View?, link: String? -> + Log.i(TAG, "Link action not implemented $view / $link") + } + } + }) + .usePlugin(TaskListPlugin.create(drawable)) + .usePlugin(TablePlugin.create { _ -> }) + .usePlugin(StrikethroughPlugin.create()) + .build() + cachedMarkwon = markwon + cachedContextRef = WeakReference(context) + cachedTextColor = textColor + return markwon + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63d6f48c41..e967920176 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -886,6 +886,7 @@ How to translate with transifex: Conversation is read only Edit message (edited) + Silent message Conversation not found Add to Notes Edited by admin diff --git a/app/src/test/java/com/nextcloud/talk/ui/chat/ResolveMarkdownSourceTest.kt b/app/src/test/java/com/nextcloud/talk/ui/chat/ResolveMarkdownSourceTest.kt new file mode 100644 index 0000000000..bab7e667aa --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/ui/chat/ResolveMarkdownSourceTest.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class ResolveMarkdownSourceTest { + + private fun makeMessage(plainMessage: String, params: Map> = emptyMap()) = + ChatMessageUi( + id = 1, + message = plainMessage, + plainMessage = plainMessage, + renderMarkdown = true, + actorDisplayName = "Test", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = 0L, + date = LocalDate.now(), + content = null, + messageParameters = params + ) + + @Test + fun `mention tokens are replaced with at-name`() { + val message = makeMessage( + "Hello {mention1}!", + mapOf("mention1" to mapOf("type" to "user", "name" to "alice")) + ) + assertEquals("Hello @alice!", resolveMarkdownSource(message)) + } + + @Test + fun `non-mention parameter tokens are replaced with name only`() { + val message = makeMessage( + "See {file1} here", + mapOf("file1" to mapOf("type" to "file", "name" to "report.pdf")) + ) + assertEquals("See report.pdf here", resolveMarkdownSource(message)) + } + + @Test + fun `message without parameters is returned unchanged`() { + val message = makeMessage("No params here") + assertEquals("No params here", resolveMarkdownSource(message)) + } + + @Test + fun `multiple tokens are all replaced`() { + val message = makeMessage( + "{user1} and {user2} joined", + mapOf( + "user1" to mapOf("type" to "user", "name" to "bob"), + "user2" to mapOf("type" to "guest", "name" to "carol") + ) + ) + assertEquals("@bob and @carol joined", resolveMarkdownSource(message)) + } + + @Test + fun `call mention type receives at-name prefix`() { + val message = makeMessage( + "Calling {call1}", + mapOf("call1" to mapOf("type" to "call", "name" to "all")) + ) + assertEquals("Calling @all", resolveMarkdownSource(message)) + } + + @Test + fun `user-group mention type receives at-name prefix`() { + val message = makeMessage( + "Hello {group1}", + mapOf("group1" to mapOf("type" to "user-group", "name" to "devs")) + ) + assertEquals("Hello @devs", resolveMarkdownSource(message)) + } +} diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 24702d5bcb..a0c1e5f3ef 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 88 warnings + Lint Report: 89 warnings