From 18795b3633c02ef6174562e666be1279e4e60e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Wed, 15 Nov 2023 21:34:30 +0800 Subject: [PATCH] feat: search message highlight [WPB-5163] (#2426) --- .../ui/home/conversations/MessageItem.kt | 4 + .../home/conversations/model/MessageTypes.kt | 2 + .../conversations/search/HighLightName.kt | 25 ++---- .../search/HighLightSubtTitle.kt | 25 +----- ...SearchConversationMessagesResultsScreen.kt | 2 + .../SearchConversationMessagesScreen.kt | 1 + .../SearchConversationMessagesViewModel.kt | 4 +- .../android/ui/markdown/MarkdownComposer.kt | 41 +++++++-- .../com/wire/android/ui/markdown/NodeData.kt | 1 + .../wire/android/util/QueryMatchExtractor.kt | 48 +++++------ .../util/debug/FeatureVisibilityFlags.kt | 2 +- ...SearchConversationMessagesViewModelTest.kt | 86 +++++++++++++++++++ 12 files changed, 163 insertions(+), 78 deletions(-) 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 ed002d0145b..12437d98189 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 @@ -92,6 +92,7 @@ import com.wire.kalium.logic.data.user.UserId fun MessageItem( message: UIMessage.Regular, conversationDetailsData: ConversationDetailsData, + searchQuery: String = "", showAuthor: Boolean = true, audioMessagesState: Map, onLongClicked: (UIMessage.Regular) -> Unit, @@ -236,6 +237,7 @@ fun MessageItem( MessageContent( message = message, messageContent = messageContent, + searchQuery = searchQuery, audioMessagesState = audioMessagesState, onAudioClick = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, @@ -463,6 +465,7 @@ private fun Username(username: String, modifier: Modifier = Modifier) { private fun MessageContent( message: UIMessage.Regular, messageContent: UIMessageContent.Regular?, + searchQuery: String, audioMessagesState: Map, onAssetClick: Clickable, onImageClick: Clickable, @@ -498,6 +501,7 @@ private fun MessageContent( } MessageBody( messageBody = messageContent.messageBody, + searchQuery = searchQuery, isAvailable = !message.isPending && message.isAvailable, onLongClick = onLongClick, onOpenProfile = onOpenProfile, 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 bde789716f7..27291d1299f 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 @@ -79,6 +79,7 @@ internal fun MessageBody( messageId: String, messageBody: MessageBody?, isAvailable: Boolean, + searchQuery: String = "", onLongClick: (() -> Unit)? = null, onOpenProfile: (String) -> Unit, buttonList: List?, @@ -94,6 +95,7 @@ internal fun MessageBody( style = MaterialTheme.wireTypography.body01, colorScheme = MaterialTheme.wireColorScheme, typography = MaterialTheme.wireTypography, + searchQuery = searchQuery, mentions = displayMentions, onLongClick = onLongClick, onOpenProfile = onOpenProfile, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt index f1adc99c209..6263397fb50 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt @@ -23,12 +23,6 @@ package com.wire.android.ui.home.conversations.search import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -39,9 +33,7 @@ import com.wire.android.R import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY -import com.wire.android.util.MatchQueryResult import com.wire.android.util.QueryMatchExtractor -import kotlinx.coroutines.launch @Composable fun HighlightName( @@ -49,21 +41,14 @@ fun HighlightName( searchQuery: String, modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() - var highlightIndexes by remember { - mutableStateOf(emptyList()) - } val queryWithoutSuffix = searchQuery.removeQueryPrefix() - SideEffect { - scope.launch { - highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( - matchText = queryWithoutSuffix, - text = name - ) - } - } + val highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( + matchText = queryWithoutSuffix, + text = name + ) + if (queryWithoutSuffix != String.EMPTY && highlightIndexes.isNotEmpty()) { Text( buildAnnotatedString { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt index 890cd4d53eb..163f174713a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt @@ -23,12 +23,6 @@ package com.wire.android.ui.home.conversations.search import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow @@ -36,9 +30,7 @@ import androidx.compose.ui.text.withStyle import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY -import com.wire.android.util.MatchQueryResult import com.wire.android.util.QueryMatchExtractor -import kotlinx.coroutines.launch @Composable fun HighlightSubtitle( @@ -46,21 +38,12 @@ fun HighlightSubtitle( searchQuery: String = "", suffix: String = "@" ) { - val scope = rememberCoroutineScope() - var highlightIndexes by remember { - mutableStateOf(emptyList()) - } - val queryWithoutSuffix = searchQuery.removeQueryPrefix() - SideEffect { - scope.launch { - highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( - matchText = queryWithoutSuffix, - text = subTitle - ) - } - } + val highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( + matchText = queryWithoutSuffix, + text = subTitle + ) if (queryWithoutSuffix != String.EMPTY && highlightIndexes.isNotEmpty()) { Text( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt index 27aae00a441..4723dbeb0a0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -31,6 +31,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun SearchConversationMessagesResultsScreen( searchResult: List, + searchQuery: String = "", onMessageClick: (messageId: String) -> Unit ) { LazyColumn { @@ -40,6 +41,7 @@ fun SearchConversationMessagesResultsScreen( MessageItem( message = message, conversationDetailsData = ConversationDetailsData.None, + searchQuery = searchQuery, audioMessagesState = mapOf(), onLongClicked = { }, onAssetMessageClicked = { }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt index 230110cba0e..a179aee86a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt @@ -104,6 +104,7 @@ fun SearchConversationMessagesResultContent( } else { SearchConversationMessagesResultsScreen( searchResult = searchResult, + searchQuery = searchQuery, onMessageClick = onMessageClick ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt index 2ee07dd3a06..e80a42176b2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt @@ -89,9 +89,9 @@ class SearchConversationMessagesViewModel @Inject constructor( val textQueryChanged = searchConversationMessagesState.searchQuery.text != searchQuery.text // we set the state with a searchQuery, immediately to update the UI first searchConversationMessagesState = searchConversationMessagesState.copy(searchQuery = searchQuery) - if (textQueryChanged) { + if (textQueryChanged && searchQuery.text.isNotBlank()) { viewModelScope.launch { - mutableSearchQueryFlow.emit(searchQuery.text) + mutableSearchQueryFlow.emit(searchQuery.text.trim()) } } } 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 99abdd3f29c..e22872d0b57 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 @@ -31,6 +31,8 @@ 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 @@ -203,6 +205,7 @@ fun appendLinksAndMentions( val stringBuilder = StringBuilder(string) val updatedMentions = nodeData.mentions.toMutableList() + 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()) { @@ -229,6 +232,13 @@ fun appendLinksAndMentions( listOf() } + if (nodeData.searchQuery.isNotBlank()) { + highlightIndexes = QueryMatchExtractor.extractQueryMatchIndexes( + matchText = nodeData.searchQuery, + text = stringBuilder.toString() + ) + } + val linkInfos = LinkSpannableString.getLinkInfos(stringBuilder.toString(), Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES) val append = buildAnnotatedString { @@ -257,27 +267,42 @@ fun appendLinksAndMentions( } if (mentionList.isNotEmpty()) { - mentionList.forEach { - if (it.length <= 0 || it.start >= length || it.start + it.length > length) { + mentionList.forEach { mention -> + if (mention.length <= 0 || mention.start >= length || mention.start + mention.length > length) { return@forEach } addStyle( style = SpanStyle( fontWeight = nodeData.typography.body02.fontWeight, color = onPrimaryVariant, - background = if (it.isSelfMention) primaryVariant else Color.Unspecified + background = if (mention.isSelfMention) primaryVariant else Color.Unspecified ), - start = it.start, - end = it.start + it.length + start = mention.start, + end = mention.start + mention.length ) addStringAnnotation( tag = MarkdownConstants.TAG_MENTION, - annotation = it.userId.toString(), - start = it.start, - end = it.start + it.length + annotation = mention.userId.toString(), + start = mention.start, + end = mention.start + mention.length ) } } + + highlightIndexes + .forEach { highLightIndex -> + if (highLightIndex.endIndex <= length) { + addStyle( + style = SpanStyle( + background = nodeData.colorScheme.highLight.copy(alpha = 0.5f), + fontFamily = nodeData.typography.body02.fontFamily, + fontWeight = FontWeight.Bold + ), + start = highLightIndex.startIndex, + end = highLightIndex.endIndex + ) + } + } } } annotatedString.append(append) diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt index 8d5dec26fe3..3741b7edc90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt @@ -31,6 +31,7 @@ data class NodeData( val colorScheme: WireColorScheme, val typography: WireTypography, val mentions: List, + val searchQuery: String, val onLongClick: (() -> Unit)? = null, val onOpenProfile: (String) -> Unit, val onLinkClick: (String) -> Unit diff --git a/app/src/main/kotlin/com/wire/android/util/QueryMatchExtractor.kt b/app/src/main/kotlin/com/wire/android/util/QueryMatchExtractor.kt index b53b23eeb12..f977e491a53 100644 --- a/app/src/main/kotlin/com/wire/android/util/QueryMatchExtractor.kt +++ b/app/src/main/kotlin/com/wire/android/util/QueryMatchExtractor.kt @@ -20,9 +20,6 @@ package com.wire.android.util -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - object QueryMatchExtractor { /** * extractHighLightIndexes is a recursive function returning a list of the start index and end index @@ -30,35 +27,34 @@ object QueryMatchExtractor { * [resultMatches] contains a list of QueryResult with startIndex and endIndex if the match is found. * [startIndex] is a index from which we start the search */ - suspend fun extractQueryMatchIndexes( + fun extractQueryMatchIndexes( resultMatches: List = emptyList(), startIndex: Int = 0, matchText: String, text: String - ): List = - withContext(Dispatchers.Default) { - if (matchText.isEmpty()) { - return@withContext listOf() - } - val index = text.indexOf(matchText, startIndex = startIndex, ignoreCase = true) + ): List { + if (matchText.isEmpty()) { + return listOf() + } + val index = text.indexOf(matchText, startIndex = startIndex, ignoreCase = true) - if (isIndexFound(index)) { - extractQueryMatchIndexes( - resultMatches = resultMatches + MatchQueryResult( - startIndex = index, - endIndex = index + matchText.length - ), - // we are incrementing the startIndex by 1 for the next recursion - // to start looking for the match from the next index that we ended up - // finding the match for the matchText - startIndex = index + 1, - matchText = matchText, - text = text - ) - } else { - resultMatches - } + return if (isIndexFound(index)) { + extractQueryMatchIndexes( + resultMatches = resultMatches + MatchQueryResult( + startIndex = index, + endIndex = index + matchText.length + ), + // we are incrementing the startIndex by 1 for the next recursion + // to start looking for the match from the next index that we ended up + // finding the match for the matchText + startIndex = index + 1, + matchText = matchText, + text = text + ) + } else { + resultMatches } + } private fun isIndexFound(index: Int): Boolean { return index != -1 diff --git a/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt b/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt index 0ae4cac30ae..d6ca757d282 100644 --- a/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt +++ b/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt @@ -56,7 +56,7 @@ object FeatureVisibilityFlags { const val ConversationSearchIcon = false const val UserProfileEditIcon = false const val MessageEditIcon = true - const val SearchConversationMessages = false + const val SearchConversationMessages = true } val LocalFeatureVisibilityFlags = staticCompositionLocalOf { FeatureVisibilityFlags } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt index 0db253effeb..f3f6b45866c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt @@ -95,6 +95,92 @@ class SearchConversationMessagesViewModelTest { } } + @Test + fun `given search term with empty space at start and at end, when searching for messages, then specific messages are returned`() = + runTest() { + // given + val searchTerm = "message" + val messages = listOf( + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message1 ") + ) + ) + ), + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message2 ") + ) + ) + ) + ) + + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .withSuccessSearch(searchTerm, messages) + .arrange() + + // when + viewModel.searchQueryChanged(TextFieldValue(searchTerm)) + advanceUntilIdle() + + // then + assertEquals( + TextFieldValue(searchTerm.trim()), + viewModel.searchConversationMessagesState.searchQuery + ) + coVerify(exactly = 1) { + arrangement.getSearchMessagesForConversation( + searchTerm, + arrangement.conversationId + ) + } + } + + @Test + fun `given blank search term, when searching for messages, then search is not triggered`() = + runTest() { + // given + val searchTerm = " " + val messages = listOf( + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message1") + ) + ) + ), + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message2") + ) + ) + ) + ) + + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .withSuccessSearch(searchTerm.trim(), messages) + .arrange() + + // when + viewModel.searchQueryChanged(TextFieldValue(searchTerm)) + advanceUntilIdle() + + // then + assertEquals( + TextFieldValue(searchTerm), + viewModel.searchConversationMessagesState.searchQuery + ) + coVerify(exactly = 0) { + arrangement.getSearchMessagesForConversation( + searchTerm, + arrangement.conversationId + ) + } + } + class SearchConversationMessagesViewModelArrangement { val conversationId: ConversationId = ConversationId( value = "some-dummy-value",