From 94d9786ffe88ebd48ed2545f1d68843e755af747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Thu, 7 Mar 2024 11:18:29 +0100 Subject: [PATCH] feat: filter markdown by query [5164] (#2752) --- .../mapper/RegularMessageContentMapper.kt | 2 +- .../home/conversations/ConversationScreen.kt | 10 +- .../ui/home/conversations/MessageItem.kt | 15 +- .../conversations/media/FileAssetsContent.kt | 2 +- .../ui/home/conversations/mock/Mock.kt | 21 ++ .../home/conversations/model/MessageTypes.kt | 8 +- .../model/MessageTypesPreview.kt | 110 +++++- .../ui/home/conversations/model/UIMessage.kt | 3 +- .../android/ui/markdown/MarkdownBlockQuote.kt | 25 +- .../android/ui/markdown/MarkdownCodeBlock.kt | 10 +- .../android/ui/markdown/MarkdownComposer.kt | 195 +++++----- .../android/ui/markdown/MarkdownHeading.kt | 33 +- .../android/ui/markdown/MarkdownHelper.kt | 315 ++++++++++++++++ .../wire/android/ui/markdown/MarkdownList.kt | 88 ++--- .../wire/android/ui/markdown/MarkdownNode.kt | 144 ++++++++ .../android/ui/markdown/MarkdownParagraph.kt | 36 +- .../wire/android/ui/markdown/MarkdownTable.kt | 54 ++- .../android/ui/markdown/MarkdownHelperTest.kt | 341 ++++++++++++++++++ .../wire/android/di/ViewModelScopedPreview.kt | 2 +- 19 files changed, 1155 insertions(+), 259 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt diff --git a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt index b65e2215e48..b65d0c90804 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt @@ -116,7 +116,7 @@ class RegularMessageMapper @Inject constructor( text = it.text, isSelected = it.isSelected ) - } + }.toPersistentList() ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 0051f751a78..3850f335e0b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -896,10 +896,10 @@ fun MessageList( val prevItemCount = remember { mutableStateOf(lazyPagingMessages.itemCount) } LaunchedEffect(lazyPagingMessages.itemCount) { if (lazyPagingMessages.itemCount > prevItemCount.value && selectedMessageId == null) { - if (prevItemCount.value > 0 - && lazyListState.firstVisibleItemIndex > 0 - && lazyListState.firstVisibleItemIndex <= MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS - ) { + val canScrollToLastMessage = prevItemCount.value > 0 + && lazyListState.firstVisibleItemIndex > 0 + && lazyListState.firstVisibleItemIndex <= MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS + if (canScrollToLastMessage) { lazyListState.animateScrollToItem(0) } prevItemCount.value = lazyPagingMessages.itemCount @@ -965,7 +965,7 @@ fun MessageList( showAuthor = showAuthor, useSmallBottomPadding = useSmallBottomPadding, audioMessagesState = audioMessagesState, - assetStatus = assetStatuses[message.header.messageId], + assetStatus = assetStatuses[message.header.messageId]?.transferStatus, onAudioClick = onAudioItemClicked, onChangeAudioPosition = onChangeAudioPosition, onLongClicked = onShowEditingOption, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt index 8ffce4bfeb4..54886100840 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt @@ -87,7 +87,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.launchGeoIntent import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.isSaved -import com.wire.kalium.logic.data.message.MessageAssetStatus import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.PersistentMap @@ -102,7 +101,7 @@ fun MessageItem( showAuthor: Boolean = true, useSmallBottomPadding: Boolean = false, audioMessagesState: PersistentMap, - assetStatus: MessageAssetStatus? = null, + assetStatus: AssetTransferStatus? = null, onLongClicked: (UIMessage.Regular) -> Unit, onAssetMessageClicked: (String) -> Unit, onAudioClick: (String) -> Unit, @@ -132,7 +131,7 @@ fun MessageItem( ) { selfDeletionTimerState.startDeletionTimer( message = message, - assetTransferStatus = assetStatus?.transferStatus, + assetTransferStatus = assetStatus, onStartMessageSelfDeletion = onSelfDeletingMessageRead ) } @@ -231,7 +230,7 @@ fun MessageItem( MessageAuthorRow(messageHeader = message.header) } if (selfDeletionTimerState is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { - MessageExpireLabel(messageContent, assetStatus?.transferStatus, selfDeletionTimerState.timeLeftFormatted) + MessageExpireLabel(messageContent, assetStatus, selfDeletionTimerState.timeLeftFormatted) // if the message is marked as deleted and is [SelfDeletionTimer.SelfDeletionTimerState.Expirable] // the deletion responsibility belongs to the receiver, therefore we need to wait for the receiver @@ -507,8 +506,8 @@ private fun MessageContent( message: UIMessage.Regular, messageContent: UIMessageContent.Regular?, searchQuery: String, - audioMessagesState: Map, - assetStatus: MessageAssetStatus?, + audioMessagesState: PersistentMap, + assetStatus: AssetTransferStatus?, onAssetClick: Clickable, onImageClick: Clickable, onAudioClick: (String) -> Unit, @@ -525,7 +524,7 @@ private fun MessageContent( MessageImage( asset = messageContent.asset, imgParams = ImageMessageParams(messageContent.width, messageContent.height), - transferStatus = assetStatus?.transferStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onImageClick = onImageClick ) PartialDeliveryInformation(messageContent.deliveryStatus) @@ -593,7 +592,7 @@ private fun MessageContent( assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, assetSizeInBytes = messageContent.assetSizeInBytes, - assetTransferStatus = assetStatus?.transferStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + assetTransferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onAssetClick = onAssetClick ) PartialDeliveryInformation(messageContent.deliveryStatus) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt index fe5ae0d6eba..a3cc1084aa4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -120,7 +120,7 @@ private fun AssetMessagesListContent( message = message, conversationDetailsData = ConversationDetailsData.None, audioMessagesState = audioMessagesState, - assetStatus = assetStatuses[message.header.messageId], + assetStatus = assetStatuses[message.header.messageId]?.transferStatus, onLongClicked = { }, onAssetMessageClicked = onAssetItemClicked, onAudioClick = onAudioItemClicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt index 631b5db5be7..68f9ce20fd2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt @@ -88,6 +88,27 @@ val mockMessageWithText = UIMessage.Regular( messageFooter = mockEmptyFooter ) +val mockMessageWithTextLoremIpsum = UIMessage.Regular( + userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), + header = mockHeader, + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus volutpat lorem tortor, " + + "nec porttitor sapien pulvinar eu. Nullam orci dolor, eleifend quis massa non, posuere bibendum risus. " + + "Praesent velit ipsum, hendrerit et ante in, placerat pretium nunc. Sed orci velit, venenatis non vulputate non, " + + "venenatis sit amet enim. Quisque vestibulum, ligula in interdum rhoncus, magna ante porta velit, " + + "ut dignissim augue est et leo. Vestibulum in nunc eu velit elementum porttitor vitae eu nunc. " + + "Aliquam consectetur orci sit amet turpis consectetur, ut tempus velit pulvinar. Pellentesque et lorem placerat, " + + "aliquet odio non, consequat metus. Maecenas ultricies mauris quis lorem cursus dignissim. " + + "Nullam lacinia, nisl et dapibus consequat, sapien dolor maximus erat, quis aliquet dolor elit tincidunt orci." + ) + ) + ), + source = MessageSource.Self, + messageFooter = mockEmptyFooter +) + val mockMessageWithMarkdownTextAndLinks = UIMessage.Regular( userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), header = mockHeader, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 511dcf8a234..445c4c56c2c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -56,7 +56,9 @@ import com.wire.android.ui.home.conversations.model.messagetypes.image.ImportedI import com.wire.android.ui.markdown.DisplayMention import com.wire.android.ui.markdown.MarkdownConstants.MENTION_MARK import com.wire.android.ui.markdown.MarkdownDocument +import com.wire.android.ui.markdown.MarkdownNode import com.wire.android.ui.markdown.NodeData +import com.wire.android.ui.markdown.toContent import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -67,6 +69,7 @@ import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_DOWNLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_UPLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.NOT_FOUND import com.wire.kalium.logic.data.asset.AssetTransferStatus.UPLOAD_IN_PROGRESS +import kotlinx.collections.immutable.PersistentList import okio.Path import org.commonmark.Extension import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension @@ -84,7 +87,7 @@ internal fun MessageBody( searchQuery: String = "", onLongClick: (() -> Unit)? = null, onOpenProfile: (String) -> Unit, - buttonList: List?, + buttonList: PersistentList?, onLinkClick: (String) -> Unit, clickable: Boolean = true ) { @@ -110,8 +113,9 @@ internal fun MessageBody( TablesExtension.create() ) text?.also { + val document = (Parser.builder().extensions(extensions).build().parse(it) as Document).toContent() as MarkdownNode.Document MarkdownDocument( - Parser.builder().extensions(extensions).build().parse(it) as Document, + document, nodeData, clickable ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt index 9dddc34c742..d36bd601bcd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt @@ -35,13 +35,12 @@ import com.wire.android.ui.home.conversations.mock.mockMessageWithMarkdownListAn import com.wire.android.ui.home.conversations.mock.mockMessageWithMarkdownTablesAndBlocks import com.wire.android.ui.home.conversations.mock.mockMessageWithMarkdownTextAndLinks import com.wire.android.ui.home.conversations.mock.mockMessageWithText +import com.wire.android.ui.home.conversations.mock.mockMessageWithTextLoremIpsum import com.wire.android.ui.home.conversations.mock.mockedImageUIMessage import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.asset.AssetTransferStatus -import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.message.MessageAssetStatus import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.persistentMapOf @@ -336,11 +335,7 @@ fun PreviewImageMessageUploaded() { message = mockedImageUIMessage(messageId = "assetMessageId"), conversationDetailsData = ConversationDetailsData.None, audioMessagesState = persistentMapOf(), - assetStatus = MessageAssetStatus( - "assetMessageId", - ConversationId("value", "domain"), - transferStatus = AssetTransferStatus.UPLOADED - ), + assetStatus = AssetTransferStatus.UPLOADED, onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -363,11 +358,7 @@ fun PreviewImageMessageUploading() { message = mockedImageUIMessage("assetMessageId"), conversationDetailsData = ConversationDetailsData.None, audioMessagesState = persistentMapOf(), - assetStatus = MessageAssetStatus( - "assetMessageId", - ConversationId("value", "domain"), - transferStatus = AssetTransferStatus.UPLOAD_IN_PROGRESS - ), + assetStatus = AssetTransferStatus.UPLOAD_IN_PROGRESS, onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -396,11 +387,7 @@ fun PreviewImageMessageFailedUpload() { ), conversationDetailsData = ConversationDetailsData.None, audioMessagesState = persistentMapOf(), - assetStatus = MessageAssetStatus( - "assetMessageId", - ConversationId("value", "domain"), - transferStatus = AssetTransferStatus.FAILED_UPLOAD - ), + assetStatus = AssetTransferStatus.FAILED_UPLOAD, onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -618,3 +605,92 @@ fun PreviewMessageWithMarkdownTablesAndBlocks() { ) } } + +@PreviewMultipleThemes +@Composable +fun PreviewMessageWithMarkdownQuery() { + WireTheme { + Column { + MessageItem( + message = mockMessageWithTextLoremIpsum, + searchQuery = "ed", + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), + onLongClicked = {}, + onAssetMessageClicked = {}, + onAudioClick = {}, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = {}, + onReplyClickable = null + ) + MessageItem( + message = mockMessageWithMarkdownTextAndLinks, + searchQuery = "code", + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), + onLongClicked = {}, + onAssetMessageClicked = {}, + onAudioClick = {}, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = {}, + onReplyClickable = null + ) + MessageItem( + message = mockMessageWithMarkdownTextAndLinks, + searchQuery = ".com", + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), + onLongClicked = {}, + onAssetMessageClicked = {}, + onAudioClick = {}, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = {}, + onReplyClickable = null + ) + MessageItem( + message = mockMessageWithMarkdownListAndImages, + searchQuery = "can", + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), + onLongClicked = {}, + onAssetMessageClicked = {}, + onAudioClick = {}, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = {}, + onReplyClickable = null + ) + MessageItem( + message = mockMessageWithMarkdownTablesAndBlocks, + searchQuery = "Joh", + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), + onLongClicked = {}, + onAssetMessageClicked = {}, + onAudioClick = {}, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = {}, + onReplyClickable = null + ) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index 032e68c4697..5a731e7abb8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -41,6 +41,7 @@ import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList @@ -220,7 +221,7 @@ sealed class UIMessageContent { data class Composite( val messageBody: MessageBody?, - val buttonList: List + val buttonList: PersistentList ) : Regular(), Copyable { override fun textToCopy(resources: Resources): String? = messageBody?.message?.asString(resources) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt index 911032faa82..31015c872fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt @@ -20,7 +20,6 @@ package com.wire.android.ui.markdown import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -31,10 +30,9 @@ import androidx.compose.ui.text.font.FontStyle import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography -import org.commonmark.node.BlockQuote @Composable -fun MarkdownBlockQuote(blockQuote: BlockQuote, nodeData: NodeData) { +fun MarkdownBlockQuote(blockQuote: MarkdownNode.Block.BlockQuote, nodeData: NodeData) { val color = MaterialTheme.wireColorScheme.onBackground val xOffset = dimensions().spacing12x.value Column(modifier = Modifier @@ -48,23 +46,30 @@ fun MarkdownBlockQuote(blockQuote: BlockQuote, nodeData: NodeData) { } .padding(start = dimensions().spacing16x, top = dimensions().spacing4x, bottom = dimensions().spacing4x)) { - var child = blockQuote.firstChild - while (child != null) { + blockQuote.children.map { child -> when (child) { - is BlockQuote -> MarkdownBlockQuote(child, nodeData) - else -> { + is MarkdownNode.Block.BlockQuote -> MarkdownBlockQuote(child, nodeData) + is MarkdownNode.Block.Paragraph -> { val text = buildAnnotatedString { pushStyle( MaterialTheme.wireTypography.body01.toSpanStyle() .plus(SpanStyle(fontStyle = FontStyle.Italic)) ) - inlineChildren(child, this, nodeData) + inlineNodeChildren(child.children, this, nodeData) pop() } - Text(text) + MarkdownText( + text, + onLongClick = nodeData.onLongClick, + onOpenProfile = nodeData.onOpenProfile + ) } + + else -> MarkdownNodeBlockChildren( + children = child.children.filterIsInstance(), + nodeData = nodeData + ) } - child = child.next } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt index 823debf6097..e5d6e4cb7e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt @@ -30,13 +30,11 @@ import androidx.compose.ui.text.font.FontFamily import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography -import org.commonmark.node.FencedCodeBlock -import org.commonmark.node.IndentedCodeBlock @Composable -fun MarkdownIndentedCodeBlock(indentedCodeBlock: IndentedCodeBlock) { +fun MarkdownIndentedCodeBlock(indentedCodeBlock: MarkdownNode.Block.IntendedCode, nodeData: NodeData) { Text( - text = indentedCodeBlock.literal, + text = highlightText(nodeData, indentedCodeBlock.literal), style = MaterialTheme.wireTypography.body01, fontFamily = FontFamily.Monospace, modifier = Modifier @@ -52,9 +50,9 @@ fun MarkdownIndentedCodeBlock(indentedCodeBlock: IndentedCodeBlock) { } @Composable -fun MarkdownFencedCodeBlock(fencedCodeBlock: FencedCodeBlock) { +fun MarkdownFencedCodeBlock(fencedCodeBlock: MarkdownNode.Block.FencedCode, nodeData: NodeData) { Text( - text = fencedCodeBlock.literal, + text = highlightText(nodeData, fencedCodeBlock.literal), style = MaterialTheme.wireTypography.body01, fontFamily = FontFamily.Monospace, modifier = Modifier diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt index c025fd247e5..0cdc8bfb37b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.markdown import android.text.util.Linkify +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString @@ -29,109 +30,94 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import com.wire.android.ui.common.LinkSpannableString -import com.wire.android.ui.markdown.MarkdownConstants.MENTION_MARK import com.wire.android.ui.markdown.MarkdownConstants.TAG_URL import com.wire.android.util.MatchQueryResult import com.wire.android.util.QueryMatchExtractor import com.wire.kalium.logic.data.message.mention.MessageMention -import org.commonmark.ext.gfm.strikethrough.Strikethrough -import org.commonmark.ext.gfm.tables.TableBlock -import org.commonmark.node.BlockQuote -import org.commonmark.node.BulletList -import org.commonmark.node.Code -import org.commonmark.node.Document -import org.commonmark.node.Emphasis -import org.commonmark.node.FencedCodeBlock -import org.commonmark.node.HardLineBreak -import org.commonmark.node.Heading -import org.commonmark.node.Image -import org.commonmark.node.IndentedCodeBlock -import org.commonmark.node.Link -import org.commonmark.node.Node -import org.commonmark.node.OrderedList -import org.commonmark.node.Paragraph -import org.commonmark.node.SoftLineBreak -import org.commonmark.node.StrongEmphasis -import org.commonmark.node.Text -import org.commonmark.node.ThematicBreak import kotlin.math.max import kotlin.math.min -import org.commonmark.node.Text as nodeText @Composable fun MarkdownDocument( - document: Document, + document: MarkdownNode.Document, nodeData: NodeData, clickable: Boolean ) { - MarkdownBlockChildren( - document, - nodeData, - clickable - ) + val filteredDocument = if (nodeData.searchQuery.isNotBlank()) { + document.filterNodesContainingQuery(nodeData.searchQuery) + } else { + document + } + if (filteredDocument != null) { + MarkdownNodeBlockChildren( + (filteredDocument as MarkdownNode.Document).children, + nodeData, + clickable + ) + } } @Composable -fun MarkdownBlockChildren( - parent: Node, +fun MarkdownNodeBlockChildren( + children: List, nodeData: NodeData, clickable: Boolean = true ) { - var child = parent.firstChild - var updateMentions = nodeData.mentions + val updatedNodeData = nodeData.copy(mentions = updateMentions) - while (child != null) { - val updatedNodeData = nodeData.copy(mentions = updateMentions) - when (child) { - is Document -> MarkdownDocument(child, updatedNodeData, clickable) - is BlockQuote -> MarkdownBlockQuote(child, updatedNodeData) - is ThematicBreak -> MarkdownThematicBreak() - is Heading -> MarkdownHeading(child, updatedNodeData) - is Paragraph -> MarkdownParagraph(child, updatedNodeData, clickable) { - updateMentions = it - } + Column { + children.map { node -> + when (node) { + is MarkdownNode.Block.BlockQuote -> MarkdownBlockQuote(node, updatedNodeData) + is MarkdownNode.Block.IntendedCode -> MarkdownIndentedCodeBlock(indentedCodeBlock = node, nodeData = updatedNodeData) + is MarkdownNode.Block.FencedCode -> MarkdownFencedCodeBlock(fencedCodeBlock = node, nodeData = updatedNodeData) + is MarkdownNode.Block.Heading -> MarkdownHeading(heading = node, nodeData = updatedNodeData) + is MarkdownNode.Block.ListBlock.Ordered -> MarkdownOrderedList(orderedList = node, nodeData = updatedNodeData) + is MarkdownNode.Block.ListBlock.Bullet -> MarkdownBulletList(bulletList = node, nodeData = updatedNodeData) + + is MarkdownNode.Block.Paragraph -> MarkdownParagraph( + paragraph = node, nodeData = updatedNodeData, + clickable + ) { + updateMentions = it + } - is FencedCodeBlock -> MarkdownFencedCodeBlock(child) - is IndentedCodeBlock -> MarkdownIndentedCodeBlock(child) - is BulletList -> MarkdownBulletList(child, updatedNodeData) - is OrderedList -> MarkdownOrderedList(child, updatedNodeData) - is TableBlock -> MarkdownTable(child, updatedNodeData) { - updateMentions = it + is MarkdownNode.Block.Table -> MarkdownTable(node, updatedNodeData) { + updateMentions = it + } + + is MarkdownNode.Block.ThematicBreak -> MarkdownThematicBreak() + + // Not used Blocks here + is MarkdownNode.Block.TableContent.Body -> {} + is MarkdownNode.Block.ListItem -> {} + is MarkdownNode.Block.TableContent.Head -> {} } } - child = child.next } } @Suppress("LongMethod") -fun inlineChildren( - parent: Node, +fun inlineNodeChildren( + children: List, annotatedString: AnnotatedString.Builder, nodeData: NodeData ): List { - var child = parent.firstChild var updatedMentions = nodeData.mentions - while (child != null) { + children.forEach { child -> when (child) { - is Paragraph -> - updatedMentions = inlineChildren( - child, - annotatedString, - nodeData.copy(mentions = updatedMentions), - ) - - is nodeText -> { + is MarkdownNode.Inline.Text -> { updatedMentions = appendLinksAndMentions( annotatedString, - convertTypoGraphs(child), + convertTypoGraphs(child.literal), nodeData.copy(mentions = updatedMentions) ) } - is Image -> { + is MarkdownNode.Inline.Image -> { updatedMentions = appendLinksAndMentions( annotatedString, child.destination, @@ -139,47 +125,43 @@ fun inlineChildren( ) } - is Emphasis -> { + is MarkdownNode.Inline.Emphasis -> { annotatedString.pushStyle( SpanStyle( fontFamily = nodeData.typography.body05.fontFamily, fontStyle = FontStyle.Italic ) ) - updatedMentions = inlineChildren( - child, + updatedMentions = inlineNodeChildren( + child.children, annotatedString, nodeData ) annotatedString.pop() } - is StrongEmphasis -> { + is MarkdownNode.Inline.StrongEmphasis -> { annotatedString.pushStyle( SpanStyle( fontFamily = nodeData.typography.body02.fontFamily, fontWeight = FontWeight.Bold ) ) - updatedMentions = inlineChildren( - child, + updatedMentions = inlineNodeChildren( + child.children, annotatedString, nodeData ) annotatedString.pop() } - is Code -> { + is MarkdownNode.Inline.Code -> { annotatedString.pushStyle(TextStyle(fontFamily = FontFamily.Monospace).toSpanStyle()) - annotatedString.append(child.literal) + annotatedString.append(highlightText(nodeData, child.literal)) annotatedString.pop() } - is HardLineBreak, is SoftLineBreak -> { - annotatedString.append("\n") - } - - is Link -> { + is MarkdownNode.Inline.Link -> { annotatedString.pushStyle( SpanStyle( color = nodeData.colorScheme.primary, @@ -187,8 +169,8 @@ fun inlineChildren( ) ) annotatedString.pushStringAnnotation(TAG_URL, child.destination) - updatedMentions = inlineChildren( - child, + updatedMentions = inlineNodeChildren( + child.children, annotatedString, nodeData ) @@ -196,13 +178,14 @@ fun inlineChildren( annotatedString.pop() } - is Strikethrough -> { + is MarkdownNode.Inline.Strikethrough -> { annotatedString.pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) - updatedMentions = inlineChildren(child, annotatedString, nodeData) + updatedMentions = inlineNodeChildren(child.children, annotatedString, nodeData) annotatedString.pop() } + + is MarkdownNode.Inline.Break -> annotatedString.append("\n") } - child = child.next } return updatedMentions @@ -220,10 +203,18 @@ fun appendLinksAndMentions( var highlightIndexes = emptyList() // get mentions from text, remove mention marks and update position of mentions - val mentionList: List = if (stringBuilder.contains(MENTION_MARK) && updatedMentions.isNotEmpty()) { + val mentionList: List = if (stringBuilder.contains(MarkdownConstants.MENTION_MARK) && updatedMentions.isNotEmpty()) { nodeData.mentions.mapNotNull { displayMention -> - val markedMentionLength = (MENTION_MARK + displayMention.mentionUserName + MENTION_MARK).length - val startIndex = stringBuilder.indexOf(MENTION_MARK + displayMention.mentionUserName + MENTION_MARK) + val markedMentionLength = ( + MarkdownConstants.MENTION_MARK + + displayMention.mentionUserName + + MarkdownConstants.MENTION_MARK + ).length + val startIndex = stringBuilder.indexOf( + MarkdownConstants.MENTION_MARK + + displayMention.mentionUserName + + MarkdownConstants.MENTION_MARK + ) val endIndex = startIndex + markedMentionLength if (startIndex != -1) { @@ -253,7 +244,7 @@ fun appendLinksAndMentions( val linkInfos = LinkSpannableString.getLinkInfos(stringBuilder.toString(), Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES) - val append = buildAnnotatedString { + val updatedAnnotatedString = buildAnnotatedString { append(stringBuilder) with(nodeData.colorScheme) { linkInfos.forEach { @@ -318,11 +309,41 @@ fun appendLinksAndMentions( } } } - annotatedString.append(append) + annotatedString.append(updatedAnnotatedString) return updatedMentions } -private fun convertTypoGraphs(child: Text) = child.literal +fun highlightText(nodeData: NodeData, text: String): AnnotatedString { + var highlightIndexes = emptyList() + + if (nodeData.searchQuery.isNotBlank()) { + highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( + matchText = nodeData.searchQuery, + text = text + ) + } + + return buildAnnotatedString { + append(text) + highlightIndexes + .forEach { highLightIndex -> + if (highLightIndex.endIndex <= length) { + addStyle( + style = SpanStyle( + background = nodeData.colorScheme.highlight, + color = nodeData.colorScheme.onHighlight, + fontFamily = nodeData.typography.body02.fontFamily, + fontWeight = FontWeight.Bold + ), + start = highLightIndex.startIndex, + end = highLightIndex.endIndex + ) + } + } + } +} + +private fun convertTypoGraphs(literal: String) = literal .replace("(c)", "©") .replace("(C)", "©") .replace("(r)", "®") diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt index c65942a1de4..e4154c2d0fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt @@ -24,36 +24,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import com.wire.android.ui.common.dimensions -import org.commonmark.node.Document -import org.commonmark.node.Heading @Composable @Suppress("MagicNumber") -fun MarkdownHeading(heading: Heading, nodeData: NodeData) { - val style: TextStyle? = when (heading.level) { +fun MarkdownHeading(heading: MarkdownNode.Block.Heading, nodeData: NodeData) { + val style: TextStyle = when (heading.level) { 1 -> nodeData.typography.title01 2 -> nodeData.typography.title01 3 -> nodeData.typography.title01 4 -> nodeData.typography.title01 5 -> nodeData.typography.title01 6 -> nodeData.typography.title01 - else -> null + else -> nodeData.typography.title01 } - if (style != null) { - val padding = if (heading.parent is Document) dimensions().spacing8x else dimensions().spacing0x - Box(modifier = Modifier.padding(bottom = padding)) { - val text = buildAnnotatedString { - inlineChildren(heading, this, nodeData) - } - MarkdownText( - annotatedString = text, - style = style, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile - ) + val padding = if (heading.isParentDocument) dimensions().spacing8x else dimensions().spacing0x + + Box(modifier = Modifier.padding(bottom = padding)) { + val text = buildAnnotatedString { + inlineNodeChildren(heading.children, this, nodeData) } - } else { - MarkdownBlockChildren(heading, nodeData) + MarkdownText( + annotatedString = text, + style = style, + onLongClick = nodeData.onLongClick, + onOpenProfile = nodeData.onOpenProfile + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt new file mode 100644 index 00000000000..f38ae81c551 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt @@ -0,0 +1,315 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +@file:Suppress("ComplexMethod") +package com.wire.android.ui.markdown + +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.HardLineBreak +import org.commonmark.node.Heading +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text +import org.commonmark.node.ThematicBreak + +fun T.toContent(isParentDocument: Boolean = false): MarkdownNode { + return when (this) { + is Document -> MarkdownNode.Document(convertChildren()) + is Heading -> MarkdownNode.Block.Heading(convertChildren(), isParentDocument, this.level) + is Paragraph -> MarkdownNode.Block.Paragraph(convertChildren(), isParentDocument) + is BlockQuote -> MarkdownNode.Block.BlockQuote(convertChildren(), isParentDocument) + is BulletList -> MarkdownNode.Block.ListBlock.Bullet(convertChildren(), isParentDocument, bulletMarker) + is OrderedList -> { + val listItems = convertChildren() + .mapIndexed { index, node -> + node.copy(orderNumber = this.startNumber + index) + } + MarkdownNode.Block.ListBlock.Ordered(listItems, isParentDocument, startNumber, delimiter) + } + + is FencedCodeBlock -> MarkdownNode.Block.FencedCode(isParentDocument, literal) + is IndentedCodeBlock -> MarkdownNode.Block.IntendedCode(isParentDocument, literal) + is HtmlBlock -> MarkdownNode.Inline.Text(this.literal) // TODO unsupported html + + is TableBlock -> MarkdownNode.Block.Table(convertChildren(), isParentDocument) + is TableHead -> MarkdownNode.Block.TableContent.Head(convertChildren()) + is TableRow -> MarkdownNode.TableRow(convertChildren()) + is TableCell -> MarkdownNode.TableCell(convertChildren()) + is TableBody -> MarkdownNode.Block.TableContent.Body(convertChildren()) + is ListItem -> MarkdownNode.Block.ListItem(convertChildren(), orderNumber = 1) + is Text -> MarkdownNode.Inline.Text(this.literal) + is StrongEmphasis -> MarkdownNode.Inline.StrongEmphasis(convertChildren()) + is Emphasis -> MarkdownNode.Inline.Emphasis(convertChildren()) + is Link -> MarkdownNode.Inline.Link(this.destination, this.title, convertChildren()) + is Image -> MarkdownNode.Inline.Image(this.destination, this.title, convertChildren()) + is Code -> MarkdownNode.Inline.Code(this.literal) + is HtmlInline -> MarkdownNode.Inline.Text(this.literal) // TODO unsupported html + is ThematicBreak -> MarkdownNode.Block.ThematicBreak(convertChildren(), isParentDocument) + is Strikethrough -> MarkdownNode.Inline.Strikethrough(convertChildren()) + is HardLineBreak, is SoftLineBreak -> MarkdownNode.Inline.Break(convertChildren()) + else -> throw IllegalArgumentException("Unsupported node type: ${this.javaClass.simpleName}") + } +} + +private inline fun Node.convertChildren(): List { + val children = mutableListOf() + var child = this.firstChild + while (child != null) { + child.toContent(this.parent is Document).let { + if (it is T) { + children.add(it) + } + } + child = child.next + } + return children +} + +fun MarkdownNode.filterNodesContainingQuery(query: String): MarkdownNode? { + return when (this) { + is MarkdownNode.Document, + is MarkdownNode.Block.Heading, + is MarkdownNode.Block.Paragraph, + is MarkdownNode.Block.BlockQuote, + is MarkdownNode.Block.ListBlock, + is MarkdownNode.Block.ListItem, + is MarkdownNode.Block.Table, + is MarkdownNode.Block.TableContent.Head, + is MarkdownNode.Block.TableContent.Body -> { + val filteredChildren = children.mapNotNull { it.filterNodesContainingQuery(query) } + if (filteredChildren.any { it.containsQuery(query) }) { + this.copy(children = filteredChildren) + } else { + null + } + } + + is MarkdownNode.TableRow -> { + val filteredChildren = children.mapNotNull { it.filterNodesContainingQuery(query) } + if (filteredChildren.any { it.containsQuery(query) }) { + this.copy(children = filteredChildren.filterIsInstance()) + } else { + null + } + } + + is MarkdownNode.Block.IntendedCode -> processLiteral(literal, query)?.let { this.copy(literal = it) } + is MarkdownNode.Block.FencedCode -> processLiteral(literal, query)?.let { this.copy(literal = it) } + is MarkdownNode.Inline.Text -> processLiteral(literal, query)?.let { this.copy(literal = it) } + is MarkdownNode.Inline.Code -> processLiteral(literal, query)?.let { this.copy(literal = it) } + + is MarkdownNode.Inline.Link, + is MarkdownNode.Inline.StrongEmphasis, + is MarkdownNode.Inline.Emphasis, + is MarkdownNode.Inline.Strikethrough -> { + val filteredChildren = children.mapNotNull { it.filterNodesContainingQuery(query) } + if (filteredChildren.any { it.containsQuery(query) }) { + this.copy(children = filteredChildren.filterIsInstance()) + } else { + null + } + } + + is MarkdownNode.TableCell -> { + val filteredChildren = children.mapNotNull { it.filterNodesContainingQuery(query) } + if (filteredChildren.any { it.containsQuery(query) }) { + this.copy(children = filteredChildren.filterIsInstance()) + } else { + null + } + } + + is MarkdownNode.Inline.Image -> if (title?.contains(query, ignoreCase = true) == true + || destination.contains(query, ignoreCase = true) + ) this else null + + is MarkdownNode.Block.ThematicBreak -> null + is MarkdownNode.Inline.Break -> this + } +} + +private fun MarkdownNode.containsQuery(query: String): Boolean { + return when (this) { + is MarkdownNode.Inline.Text -> literal.contains(query, ignoreCase = true) + is MarkdownNode.Block.FencedCode -> literal.contains(query, ignoreCase = true) + is MarkdownNode.Block.IntendedCode -> literal.contains(query, ignoreCase = true) + is MarkdownNode.Inline.Code -> literal.contains(query, ignoreCase = true) + is MarkdownNode.Inline.Link -> title?.contains(query, ignoreCase = true) == true || children.anyContainsQuery(query) + is MarkdownNode.Inline.Image -> title?.contains(query, ignoreCase = true) == true || children.anyContainsQuery(query) + else -> children.anyContainsQuery(query) + } +} + +private fun processLiteral(inputString: String, queryString: String, maxWordsAround: Int = 3): String? { + val matches = queryString.toRegex(option = RegexOption.IGNORE_CASE).findAll(inputString).toList() + if (matches.isEmpty()) return null + + val result = StringBuilder() + var lastEnd = 0 + + matches.forEachIndexed { index, matchResult -> + val matchStart = matchResult.range.first + val matchEnd = matchResult.range.last + 1 + + val contextStart = maxOf(findContextStart(inputString, matchStart, maxWordsAround), lastEnd) + val contextEnd = findContextEnd(inputString, matchEnd, maxWordsAround) + + if (index == 0 && contextStart > 0) { + result.append("...") + } + + if (index != 0 && contextStart - lastEnd > 1) { + result.append("...") + } + + if (contextStart < contextEnd) { + if (lastEnd != 0 && contextStart <= lastEnd) { + result.append(inputString.substring(lastEnd, contextEnd)) + } else { + result.append(inputString.substring(contextStart, contextEnd)) + } + } + + lastEnd = contextEnd + } + + if (lastEnd < inputString.length) { + result.append("...") + } + + return if (result.isEmpty()) null else result.toString() +} + +fun findContextStart(inputString: String, matchStart: Int, wordsAround: Int): Int { + var spaceCount = 0 + var index = matchStart - 1 + while (index >= 0 && spaceCount < wordsAround) { + if (inputString[index].isWhitespace()) spaceCount++ + index-- + } + return index + 1 +} + +fun findContextEnd(inputString: String, matchEnd: Int, wordsAround: Int): Int { + var spaceCount = 0 + var index = matchEnd + while (index < inputString.length && spaceCount < wordsAround) { + if (inputString[index].isWhitespace()) spaceCount++ + index++ + } + return index +} + +private fun List.anyContainsQuery(query: String): Boolean { + return any { it.containsQuery(query) } +} + +private fun MarkdownNode.copy(children: List): MarkdownNode { + return when (this) { + is MarkdownNode.Document -> this.copy(children = children.filterIsInstance()) + // Block nodes + is MarkdownNode.Block.Heading -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.Paragraph -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.BlockQuote -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.ListBlock.Bullet -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.ListBlock.Ordered -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.ListItem -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.IntendedCode -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.FencedCode -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.Table -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.TableContent.Head -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.TableContent.Body -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Block.ThematicBreak -> this.copy(children = children.filterIsInstance()) + + // Inline Nodes + is MarkdownNode.Inline.Text -> this + is MarkdownNode.Inline.StrongEmphasis -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Emphasis -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Link -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Image -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Code -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Strikethrough -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.Inline.Break -> this + // Custom nodes + is MarkdownNode.TableRow -> this.copy(children = children.filterIsInstance()) + is MarkdownNode.TableCell -> this.copy(children = children.filterIsInstance()) + } +} + +// use it to investigate when some of the markdown messages are not properly showed +fun printMarkdownNodeTree(node: MarkdownNode?, indentLevel: Int = 0) { + node ?: return + + val indent = " ".repeat(indentLevel * 2) + val printLog = when (node) { + is MarkdownNode.Document -> "${indent}Document: [${node.children.size} children]" + is MarkdownNode.Block.Heading -> "${indent}Heading(level=${node.level}): [${node.children.size} children]" + is MarkdownNode.Block.Paragraph -> "${indent}Paragraph: [${node.children.size} children]" + is MarkdownNode.Block.BlockQuote -> "${indent}BlockQuote: [${node.children.size} children]" + is MarkdownNode.Block.ListBlock.Bullet -> + "${indent}BulletList(marker=${node.bulletMarker}): [${node.children.size} children]" + + is MarkdownNode.Block.ListBlock.Ordered -> + "${indent}OrderedList(startNumber=${node.startNumber}, delimiter=${node.delimiter}): [${node.children.size} children]" + + is MarkdownNode.Block.ListItem -> "${indent}ListItem: [${node.children.size} children]" + is MarkdownNode.Block.IntendedCode -> + "${indent}IntendedCode: [${node.children.size} children] '${node.literal}'" + + is MarkdownNode.Block.FencedCode -> "${indent}FencedCode: [${node.children.size} children] '${node.literal}'" + is MarkdownNode.Block.Table -> "${indent}Table: [${node.children.size} children]" + is MarkdownNode.Block.TableContent.Head -> "${indent}TableHead: [${node.children.size} children]" + is MarkdownNode.Block.TableContent.Body -> "${indent}TableBody: [${node.children.size} children]" + is MarkdownNode.TableRow -> "${indent}TableRow: [${node.children.size} children]" + is MarkdownNode.Block.ThematicBreak -> "${indent}ThematicBreak" + + is MarkdownNode.TableCell -> "${indent}TableCell: [${node.children.size} children]" + is MarkdownNode.Inline.Text -> "${indent}Text: '${node.literal}'" + is MarkdownNode.Inline.Link -> + "${indent}Link(destination='${node.destination}', title='${node.title}'): [${node.children.size} children]" + + is MarkdownNode.Inline.Image -> "${indent}Image(destination='${node.destination}', title='${node.title}')" + is MarkdownNode.Inline.StrongEmphasis -> "${indent}StrongEmphasis: [${node.children.size} children]" + is MarkdownNode.Inline.Emphasis -> "${indent}Emphasis: [${node.children.size} children]" + is MarkdownNode.Inline.Code -> "${indent}Code: '${node.literal}'" + is MarkdownNode.Inline.Strikethrough -> "${indent}Strikethrough: [${node.children.size} children]" + is MarkdownNode.Inline.Break -> "${indent}Break" + } + println(printLog) + + node.children.forEach { printMarkdownNodeTree(it, indentLevel + 1) } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt index abbd0372963..be004646021 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.markdown import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -26,66 +27,53 @@ import androidx.compose.ui.text.buildAnnotatedString import com.wire.android.ui.common.dimensions import com.wire.android.ui.markdown.MarkdownConstants.BULLET_MARK import com.wire.android.ui.theme.wireTypography -import org.commonmark.node.BulletList -import org.commonmark.node.Document -import org.commonmark.node.ListBlock -import org.commonmark.node.Node -import org.commonmark.node.OrderedList @Composable -fun MarkdownBulletList(bulletList: BulletList, nodeData: NodeData) { - MarkdownListItems(bulletList, nodeData) { - val text = buildAnnotatedString { - pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) - append("$BULLET_MARK ") - inlineChildren(it, this, nodeData) - pop() - } - MarkdownText(annotatedString = text, - style = MaterialTheme.wireTypography.body01, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile - ) +fun MarkdownBulletList(bulletList: MarkdownNode.Block.ListBlock.Bullet, nodeData: NodeData) { + val bottom = if (bulletList.isParentDocument) dimensions().spacing8x else dimensions().spacing0x + + val text = buildAnnotatedString { + pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) + append("$BULLET_MARK ") + pop() } -} -@Composable -fun MarkdownOrderedList(orderedList: OrderedList, nodeData: NodeData) { - var number = orderedList.startNumber - val delimiter = orderedList.delimiter - MarkdownListItems(orderedList, nodeData) { - val text = buildAnnotatedString { - pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) - append("${number++}$delimiter ") - inlineChildren(it, this, nodeData) - pop() + Column(modifier = Modifier.padding(bottom = bottom)) { + bulletList.children.forEach { listItem -> + Row { + MarkdownText( + annotatedString = text, + style = MaterialTheme.wireTypography.body01, + onLongClick = nodeData.onLongClick, + onOpenProfile = nodeData.onOpenProfile + ) + MarkdownNodeBlockChildren(children = listItem.children, nodeData = nodeData) + } } - MarkdownText( - annotatedString = text, - style = MaterialTheme.wireTypography.body01, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile - ) } } @Composable -fun MarkdownListItems(listBlock: ListBlock, nodeData: NodeData, item: @Composable (node: Node) -> Unit) { - val bottom = if (listBlock.parent is Document) dimensions().spacing8x else dimensions().spacing0x - val start = if (listBlock.parent is Document) dimensions().spacing0x else dimensions().spacing8x - Column(modifier = Modifier.padding(start = start, bottom = bottom)) { - var listItem = listBlock.firstChild - while (listItem != null) { - var child = listItem.firstChild - while (child != null) { - when (child) { - is BulletList -> MarkdownBulletList(child, nodeData) - is OrderedList -> MarkdownOrderedList(child, nodeData) - else -> item(child) - } - child = child.next +fun MarkdownOrderedList(orderedList: MarkdownNode.Block.ListBlock.Ordered, nodeData: NodeData) { + val bottom = if (orderedList.isParentDocument) dimensions().spacing8x else dimensions().spacing0x + + Column(modifier = Modifier.padding(bottom = bottom)) { + orderedList.children.forEach { listItem -> + val text = buildAnnotatedString { + pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) + append("${listItem.orderNumber}${orderedList.delimiter} ") + pop() + } + + Row { + MarkdownText( + annotatedString = text, + style = MaterialTheme.wireTypography.body01, + onLongClick = nodeData.onLongClick, + onOpenProfile = nodeData.onOpenProfile + ) + MarkdownNodeBlockChildren(children = listItem.children, nodeData = nodeData) } - listItem = listItem.next } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt new file mode 100644 index 00000000000..e5536f39031 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt @@ -0,0 +1,144 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.markdown + +sealed class MarkdownNode { + abstract val children: List + abstract val isParentDocument: Boolean + + data class Document( + override val children: List = listOf(), + override val isParentDocument: Boolean = false + ) : MarkdownNode() + + sealed class Block : MarkdownNode() { + data class Heading( + override val children: List = listOf(), + override val isParentDocument: Boolean, + val level: Int + ) : Block() + + data class Paragraph( + override val children: List = listOf(), + override val isParentDocument: Boolean + ) : Block() + + data class BlockQuote( + override val children: List = listOf(), + override val isParentDocument: Boolean + ) : Block() + + sealed class ListBlock(override val children: List) : Block() { + data class Bullet( + override val children: List, + override val isParentDocument: Boolean = false, + val bulletMarker: Char + ) : ListBlock(children) + + data class Ordered( + override val children: List, + override val isParentDocument: Boolean = false, + val startNumber: Int, + val delimiter: Char, + ) : ListBlock(children) + } + + data class ListItem( + override val children: List = listOf(), + override val isParentDocument: Boolean = false, + val orderNumber: Int + ) : Block() + + data class IntendedCode( + override val isParentDocument: Boolean, + val literal: String + ) : Block() { + override val children: List + get() = listOf() + } + + data class FencedCode( + override val isParentDocument: Boolean, + val literal: String + ) : Block() { + override val children: List + get() = listOf() + } + + data class Table( + override val children: List = listOf(), + override val isParentDocument: Boolean + ) : Block() + + sealed class TableContent : Block() { + data class Head( + override val children: List, + override val isParentDocument: Boolean = false + ) : TableContent() + + data class Body( + override val children: List, + override val isParentDocument: Boolean = false + ) : TableContent() + } + + data class ThematicBreak( + override val children: List = listOf(), + override val isParentDocument: Boolean + ) : Block() + } + + sealed class Inline(override val isParentDocument: Boolean = false) : MarkdownNode() { + abstract override val children: List + + data class Text(val literal: String) : Inline() { + override val children: List = listOf() + } + + data class StrongEmphasis(override val children: List = listOf()) : Inline() + data class Strikethrough(override val children: List = listOf()) : Inline() + data class Emphasis(override val children: List = listOf()) : Inline() + data class Link( + val destination: String, + val title: String?, + override val children: List = listOf() + ) : Inline() + + data class Image( + val destination: String, + val title: String?, + override val children: List = listOf() + ) : Inline() + + data class Code(val literal: String) : Inline() { + override val children: List = listOf() + } + + data class Break(override val children: List = listOf()) : Inline() + } + + data class TableRow( + override val children: List, + override val isParentDocument: Boolean = false + ) : MarkdownNode() + + data class TableCell( + override val children: List = listOf(), + override val isParentDocument: Boolean = false + ) : MarkdownNode() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt index cb805aef252..0ff5770176b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt @@ -25,31 +25,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.buildAnnotatedString import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireTypography -import org.commonmark.node.Document -import org.commonmark.node.Paragraph @Composable fun MarkdownParagraph( - paragraph: Paragraph, + paragraph: MarkdownNode.Block.Paragraph, nodeData: NodeData, clickable: Boolean, onMentionsUpdate: (List) -> Unit ) { - val padding = if (paragraph.parent is Document) dimensions().spacing4x else dimensions().spacing0x - Box(modifier = Modifier.padding(bottom = padding)) { - val annotatedString = buildAnnotatedString { - pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) - val updatedMentions = inlineChildren(paragraph, this, nodeData) - onMentionsUpdate(updatedMentions) - pop() - } - MarkdownText( - annotatedString, - style = nodeData.style, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile, - onClickLink = nodeData.onLinkClick, - clickable = clickable - ) + val padding = if (paragraph.isParentDocument) dimensions().spacing4x else dimensions().spacing0x + Box(modifier = Modifier.padding(bottom = padding)) { + val annotatedString = buildAnnotatedString { + pushStyle(MaterialTheme.wireTypography.body01.toSpanStyle()) + val updatedMentions = inlineNodeChildren(paragraph.children, this, nodeData) + onMentionsUpdate(updatedMentions) + pop() } + MarkdownText( + annotatedString, + style = nodeData.style, + onLongClick = nodeData.onLongClick, + onOpenProfile = nodeData.onOpenProfile, + onClickLink = nodeData.onLinkClick, + clickable = clickable + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt index 0a1fbcab9e1..d346f628622 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt @@ -24,50 +24,43 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireColorScheme -import org.commonmark.ext.gfm.tables.TableBlock -import org.commonmark.ext.gfm.tables.TableBody -import org.commonmark.ext.gfm.tables.TableCell -import org.commonmark.ext.gfm.tables.TableHead -import org.commonmark.node.Node @Composable -fun MarkdownTable(tableBlock: TableBlock, nodeData: NodeData, onMentionsUpdate: (List) -> Unit) { +fun MarkdownTable(tableBlock: MarkdownNode.Block.Table, nodeData: NodeData, onMentionsUpdate: (List) -> Unit) { val tableData = mutableListOf>() - var child = tableBlock.firstChild - - while (child != null) { + tableBlock.children.forEach { child -> when (child) { - is TableHead -> { - var rowNode = child.firstChild - while (rowNode != null) { - val row = parseRow(rowNode, nodeData, true, onMentionsUpdate) + is MarkdownNode.Block.TableContent.Head -> { + child.children.forEach { rowNode -> + val row = parseRowCells(rowNode.children, nodeData, true, onMentionsUpdate) tableData.add(row) - rowNode = rowNode.next } } - is TableBody -> { - var rowNode = child.firstChild - while (rowNode != null) { - val row = parseRow(rowNode, nodeData, false, onMentionsUpdate) + is MarkdownNode.Block.TableContent.Body -> { + child.children.forEach { rowNode -> + val row = parseRowCells(rowNode.children, nodeData, false, onMentionsUpdate) tableData.add(row) - rowNode = rowNode.next } } } - child = child.next } - val columnCount = tableData.firstOrNull()?.size ?: 0 + val columnCount by remember { + mutableStateOf(tableData.firstOrNull()?.size ?: 0) + } // Create a table Column(modifier = Modifier.padding(bottom = dimensions().spacing8x)) { - tableData.forEach { row -> + tableData.map { row -> Row( modifier = Modifier .fillMaxWidth() @@ -94,22 +87,19 @@ fun MarkdownTable(tableBlock: TableBlock, nodeData: NodeData, onMentionsUpdate: } } -private fun parseRow( - tableRow: Node, +private fun parseRowCells( + tableCells: List, nodeData: NodeData, isHeader: Boolean, onMentionsUpdate: (List) -> Unit ): List { val rowsData = mutableListOf() - var child = tableRow.firstChild - while (child != null) { - if (child is TableCell) { - val cellText = buildAnnotatedString { - onMentionsUpdate(inlineChildren(child, this, nodeData)) - } - rowsData.add(RowData(cellText, isHeader)) + + tableCells.forEach { child -> + val cellText = buildAnnotatedString { + onMentionsUpdate(inlineNodeChildren(child.children, this, nodeData)) } - child = child.next + rowsData.add(RowData(cellText, isHeader)) } return rowsData } diff --git a/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt b/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt new file mode 100644 index 00000000000..9cf154b67eb --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt @@ -0,0 +1,341 @@ +package com.wire.android.ui.markdown + +import org.amshove.kluent.internal.assertEquals +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.HardLineBreak +import org.commonmark.node.Heading +import org.commonmark.node.Image +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text +import org.commonmark.node.ThematicBreak +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MarkdownHelperTest { + + @Test + fun `given plain text node, when toContent is called, then it should return Inline Text`() { + val textNode = Text("Sample text") + + val result = textNode.toContent() + + assert(result is MarkdownNode.Inline.Text) + assertEquals("Sample text", (result as MarkdownNode.Inline.Text).literal) + } + + @Test + fun `given heading node, when toContent is called, then it should return Block Heading`() { + val headLevel = 1 + val headingNode = Heading().apply { level = headLevel } + headingNode.appendChild(Text("Header")) + + val result = headingNode.toContent() + + assert(result is MarkdownNode.Block.Heading) + assertEquals(headLevel, (result as MarkdownNode.Block.Heading).level) + assertEquals(headLevel, result.children.size) + } + + @Test + fun `given paragraph node, when toContent is called, then it should return Block Paragraph`() { + val paragraphNode = Paragraph() + paragraphNode.appendChild(Text("Sample paragraph")) + + val result = paragraphNode.toContent() + + assert(result is MarkdownNode.Block.Paragraph) + assertEquals(1, result.children.size) + } + + @Test + fun `given ordered list node, when toContent is called, then it should return Block OrderedList`() { + val orderedListNode = OrderedList() + orderedListNode.appendChild(ListItem().apply { appendChild(Text("First item")) }) + orderedListNode.appendChild(ListItem().apply { appendChild(Text("Second item")) }) + + val result = orderedListNode.toContent() + + assert(result is MarkdownNode.Block.ListBlock.Ordered) + assertEquals(2, (result as MarkdownNode.Block.ListBlock.Ordered).children.size) + } + + @Test + fun `given document with text, when filterNodesContainingQuery is called with query, then it should return filtered document`() { + val documentNode = Document() + documentNode.appendChild(Paragraph().apply { appendChild(Text("Hello World")) }) + documentNode.appendChild(Paragraph().apply { appendChild(Text("Another paragraph")) }) + + val result = documentNode.toContent().filterNodesContainingQuery("World") + + assert(result is MarkdownNode.Document) + assertEquals(1, (result as MarkdownNode.Document).children.size) + assert(result.children.first() is MarkdownNode.Block.Paragraph) + assertEquals( + "Hello World", + ((result.children.first() as MarkdownNode.Block.Paragraph).children.first() as MarkdownNode.Inline.Text).literal + ) + } + + @Test + fun `given bullet list node, when toContent is called, then it should return Block BulletList`() { + val bulletListNode = BulletList() + bulletListNode.appendChild(ListItem().apply { appendChild(Text("First bullet")) }) + bulletListNode.appendChild(ListItem().apply { appendChild(Text("Second bullet")) }) + + val result = bulletListNode.toContent() + + assert(result is MarkdownNode.Block.ListBlock.Bullet) + assertEquals(2, (result as MarkdownNode.Block.ListBlock.Bullet).children.size) + } + + @Test + fun `given blockquote node, when toContent is called, then it should return Block BlockQuote`() { + val blockQuoteNode = BlockQuote() + blockQuoteNode.appendChild(Paragraph().apply { appendChild(Text("Quote text")) }) + + val result = blockQuoteNode.toContent() + + assert(result is MarkdownNode.Block.BlockQuote) + assertEquals(1, result.children.size) + } + + @Test + fun `given fenced code block, when toContent is called, then it should return Block FencedCode`() { + val codeBlockNode = FencedCodeBlock() + codeBlockNode.literal = "Sample code" + + val result = codeBlockNode.toContent() + + assert(result is MarkdownNode.Block.FencedCode) + assertEquals("Sample code", (result as MarkdownNode.Block.FencedCode).literal) + } + + @Test + fun `given table block, when toContent is called, then it should return Block Table`() { + val tableBlockNode = TableBlock() + tableBlockNode.appendChild(TableHead().apply { + appendChild(TableRow() + .apply { appendChild(TableCell().apply { appendChild(Text("Header")) }) }) + }) + tableBlockNode.appendChild(TableBody().apply { + appendChild(TableRow() + .apply { appendChild(TableCell().apply { appendChild(Text("Cell")) }) }) + }) + + val result = tableBlockNode.toContent() + + assert(result is MarkdownNode.Block.Table) + assertEquals(2, (result as MarkdownNode.Block.Table).children.size) + } + + @Test + fun `given table row node, when toContent is called, then it should return TableRow`() { + val rowNode = TableRow() + rowNode.appendChild(TableCell()) + + val result = rowNode.toContent() + + assert(result is MarkdownNode.TableRow) + assertEquals(1, result.children.size) + } + + @Test + fun `given table cell node, when toContent is called, then it should return TableCell`() { + val cellNode = TableCell() + cellNode.appendChild(Text("cell content")) + + val result = cellNode.toContent() + + assert(result is MarkdownNode.TableCell) + assertEquals(1, result.children.size) + } + + @Test + fun `given image node, when toContent is called, then it should return Inline Image`() { + val imageNode = Image("image.png", "alt text") + + val result = imageNode.toContent() + + assert(result is MarkdownNode.Inline.Image) + assertEquals("image.png", (result as MarkdownNode.Inline.Image).destination) + assertEquals("alt text", result.title) + } + + @Test + fun `given emphasis node, when toContent is called, then it should return Inline Emphasis`() { + val emphasisNode = Emphasis() + emphasisNode.appendChild(Text("emphasized text")) + + val result = emphasisNode.toContent() + + assert(result is MarkdownNode.Inline.Emphasis) + assertEquals(1, result.children.size) + } + + @Test + fun `given strong emphasis node, when toContent is called, then it should return Inline StrongEmphasis`() { + val strongEmphasisNode = StrongEmphasis() + strongEmphasisNode.appendChild(Text("strong text")) + + val result = strongEmphasisNode.toContent() + + assert(result is MarkdownNode.Inline.StrongEmphasis) + assertEquals(1, result.children.size) + } + + @Test + fun `given link node, when toContent is called, then it should return Inline Link`() { + val linkNode = Link("https://example.com", "Example") + + val result = linkNode.toContent() + + assert(result is MarkdownNode.Inline.Link) + assertEquals("https://example.com", (result as MarkdownNode.Inline.Link).destination) + assertEquals("Example", result.title) + } + + @Test + fun `given code node, when toContent is called, then it should return Inline Code`() { + val codeNode = Code("inline code") + + val result = codeNode.toContent() + + assert(result is MarkdownNode.Inline.Code) + assertEquals("inline code", (result as MarkdownNode.Inline.Code).literal) + } + + @Test + fun `given hard line break node, when toContent is called, then it should return Inline Break`() { + val lineBreakNode = HardLineBreak() + + val result = lineBreakNode.toContent() + + assert(result is MarkdownNode.Inline.Break) + } + + @Test + fun `given indented code block, when toContent is called, then it should return Block IntendedCode`() { + val codeBlockNode = IndentedCodeBlock() + codeBlockNode.literal = "Sample indented code" + + val result = codeBlockNode.toContent() + + assert(result is MarkdownNode.Block.IntendedCode) + assertEquals("Sample indented code", (result as MarkdownNode.Block.IntendedCode).literal) + } + + @Test + fun `given thematic break node, when toContent is called, then it should return Block ThematicBreak`() { + val thematicBreakNode = ThematicBreak() + + val result = thematicBreakNode.toContent() + + assert(result is MarkdownNode.Block.ThematicBreak) + } + + @Test + fun `given strikethrough node, when toContent is called, then it should return Inline Strikethrough`() { + val strikethroughNode = Strikethrough() + strikethroughNode.appendChild(Text("strikethrough text")) + + val result = strikethroughNode.toContent() + + assert(result is MarkdownNode.Inline.Strikethrough) + assertEquals(1, result.children.size) + } + + @Test + fun `given text without query, filterNodesContainingQuery should return null`() { + val textNode = Text("This is a sample text without the query.").toContent() + + val result = textNode.filterNodesContainingQuery("longer query") + + assertNull(result) + } + + @Test + fun `given text with query, filterNodesContainingQuery should return non-null result`() { + val textNode = Text("Sample text with query in the middle.").toContent() + + val result = textNode.filterNodesContainingQuery("query") + + assertNotNull(result) + } + + @Test + fun `given text with multiple queries, filterNodesContainingQuery should return non-null result`() { + val textNode = Text("Query at the start and another query towards the end.").toContent() + + val result = textNode.filterNodesContainingQuery("query") + + assertNotNull(result) + } + + @Test + fun `given text with query case insensitive, filterNodesContainingQuery should return non-null result`() { + val textNode = Text("Text with Query in mixed CASE.").toContent() + + val result = textNode.filterNodesContainingQuery("query") + + assertNotNull(result) + } + + @Test + fun `given text with query at the start, filterNodesContainingQuery should prepend ellipsis when necessary`() { + val textNode = Text("query present at the very start of the text.").toContent() + + val result = textNode.filterNodesContainingQuery("query") as? MarkdownNode.Inline.Text + + assertNotNull(result) + assertTrue(result!!.literal.startsWith("query")) + } + + @Test + fun `given text with query at the end, filterNodesContainingQuery should append ellipsis when necessary`() { + val textNode = Text("Text ending with a query.").toContent() + + val result = textNode.filterNodesContainingQuery("query") as? MarkdownNode.Inline.Text + + assertNotNull(result) + assertTrue(result!!.literal.endsWith("query.")) + } + + @Test + fun `given text with multiple queries, filterNodesContainingQuery should include ellipsis between them`() { + val textNode = Text("First query, some intermediate long text, second query.").toContent() + + val result = textNode.filterNodesContainingQuery("query") as? MarkdownNode.Inline.Text + + assertNotNull(result) + assertTrue(result!!.literal.contains("...")) + } + + @Test + fun `given text with closely positioned queries, filterNodesContainingQuery should not include unnecessary ellipsis`() { + val textNode = Text("First query and immediately second query.").toContent() + + val result = textNode.filterNodesContainingQuery("query") as? MarkdownNode.Inline.Text + + assertNotNull(result) + assertFalse(result!!.literal.contains("...")) + } +} diff --git a/ksp/src/main/kotlin/com/wire/android/di/ViewModelScopedPreview.kt b/ksp/src/main/kotlin/com/wire/android/di/ViewModelScopedPreview.kt index 839707d56a4..deb7b367c5e 100644 --- a/ksp/src/main/kotlin/com/wire/android/di/ViewModelScopedPreview.kt +++ b/ksp/src/main/kotlin/com/wire/android/di/ViewModelScopedPreview.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2023 Wire Swiss GmbH + * Copyright (C) 2024 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by