From 08204c1e299792123ba45e96d7ef42df02c58b44 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Wed, 20 May 2026 16:48:45 +0200 Subject: [PATCH 1/2] refactor: improve markdown formatting and audio handling in conversation items --- .../wire/android/mapper/ConversationMapper.kt | 55 +++--------- .../mapper/MessagePreviewContentMapper.kt | 83 +++-------------- .../GetConversationsFromSearchUseCase.kt | 41 ++++++--- .../ConversationListViewModel.kt | 44 +++++---- .../ConversationsScreenContent.kt | 10 +++ .../common/ConversationItemFactory.kt | 31 +++++-- .../common/ConversationList.kt | 13 +++ .../model/ConversationItem.kt | 15 ++-- .../android/ui/sharing/ImportMediaScreen.kt | 3 + .../mapper/MessagePreviewContentMapperTest.kt | 17 ++++ .../GetConversationsFromSearchUseCaseTest.kt | 1 - .../ConversationListViewModelTest.kt | 89 +++++++++++-------- 12 files changed, 214 insertions(+), 188 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index b46424ed272..13ecc50dc18 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -17,8 +17,6 @@ */ package com.wire.android.mapper -import com.wire.android.media.audiomessage.AudioMediaPlayingState -import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData @@ -27,7 +25,10 @@ import com.wire.android.ui.home.conversationslist.model.BadgeEventType import com.wire.android.ui.home.conversationslist.model.BlockState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem -import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation +import com.wire.android.ui.home.conversationslist.model.ConversationItem.ConnectionConversation +import com.wire.android.ui.home.conversationslist.model.ConversationItem.Group.Channel +import com.wire.android.ui.home.conversationslist.model.ConversationItem.Group.Regular +import com.wire.android.ui.home.conversationslist.model.ConversationItem.PrivateConversation import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.ConversationDetails @@ -47,12 +48,10 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus fun ConversationDetailsWithEvents.toConversationItem( userTypeMapper: UserTypeMapper, uiTextResolver: UiTextResolver, - searchQuery: String, - selfUserTeamId: TeamId?, - playingAudioMessage: PlayingAudioMessage + selfUserTeamId: TeamId? ): ConversationItem = when (val conversationDetails = this.conversationDetails) { is Group.Regular -> { - ConversationItem.Group.Regular( + Regular( groupName = conversationDetails.conversation.name.orEmpty(), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, @@ -71,15 +70,13 @@ fun ConversationDetailsWithEvents.toConversationItem( mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, - searchQuery = searchQuery, isFavorite = conversationDetails.isFavorite, - folder = conversationDetails.folder, - playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) + folder = conversationDetails.folder ) } is Group.Channel -> { - ConversationItem.Group.Channel( + Channel( groupName = conversationDetails.conversation.name.orEmpty(), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, @@ -98,16 +95,14 @@ fun ConversationDetailsWithEvents.toConversationItem( mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, - searchQuery = searchQuery, isFavorite = conversationDetails.isFavorite, folder = conversationDetails.folder, - playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails), isPrivate = conversationDetails.access == Group.Channel.ChannelAccess.PRIVATE ) } is OneOne -> { - ConversationItem.PrivateConversation( + PrivateConversation( userAvatarData = UserAvatarData( asset = conversationDetails.otherUser.previewPicture?.let { UserAvatarAsset(it) }, availabilityStatus = conversationDetails.otherUser.availabilityStatus, @@ -139,15 +134,13 @@ fun ConversationDetailsWithEvents.toConversationItem( mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, - searchQuery = searchQuery, isFavorite = conversationDetails.isFavorite, - folder = conversationDetails.folder, - playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) + folder = conversationDetails.folder ) } is Connection -> { - ConversationItem.ConnectionConversation( + ConnectionConversation( userAvatarData = UserAvatarData( asset = conversationDetails.otherUser?.previewPicture?.let { UserAvatarAsset(it) }, availabilityStatus = conversationDetails.otherUser?.availabilityStatus ?: UserAvailabilityStatus.NONE, @@ -165,8 +158,7 @@ fun ConversationDetailsWithEvents.toConversationItem( badgeEventType = parseConnectionEventType(conversationDetails.connection.status), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - hasNewActivitiesToShow = hasNewActivitiesToShow, - searchQuery = searchQuery, + hasNewActivitiesToShow = hasNewActivitiesToShow ) } @@ -174,30 +166,11 @@ fun ConversationDetailsWithEvents.toConversationItem( throw IllegalArgumentException("Self conversations should not be visible to the user.") } - else -> { - throw IllegalArgumentException("$this conversations should not be visible to the user.") + is ConversationDetails.Team -> { + throw IllegalArgumentException("Team conversations should not be visible to the user.") } } -private fun getPlayingAudioInConversation( - playingAudioMessage: PlayingAudioMessage, - conversationDetails: ConversationDetails -): PlayingAudioInConversation? = - if (playingAudioMessage is PlayingAudioMessage.Some - && playingAudioMessage.conversationId == conversationDetails.conversation.id - ) { - if (playingAudioMessage.state.isPlaying()) { - PlayingAudioInConversation(playingAudioMessage.messageId, false) - } else if (playingAudioMessage.state.audioMediaPlayingState is AudioMediaPlayingState.Paused) { - PlayingAudioInConversation(playingAudioMessage.messageId, true) - } else { - // states Fetching, Completed, Stopped, etc. should not be shown in ConversationItem - null - } - } else { - null - } - private fun parseConnectionEventType(connectionState: ConnectionState) = if (connectionState == ConnectionState.SENT) { BadgeEventType.SentConnectRequest diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt index 92cc4645922..f1180612fa0 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt @@ -22,9 +22,6 @@ import com.wire.android.R import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.UILastMessageContent import com.wire.android.ui.markdown.MarkdownConstants -import com.wire.android.ui.markdown.MarkdownPreview -import com.wire.android.ui.markdown.getFirstInlines -import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.util.ui.UIText import com.wire.android.util.ui.UiTextResolver import com.wire.android.util.ui.toUIText @@ -55,11 +52,11 @@ fun MessagePreview?.toUIPreview( unreadEventCount.size == 1 && unreadEventCount.values.first() == 1 -> uiLastMessageContent(uiTextResolver) // for the rest take 1 or 2 most prioritized events with count to last message - else -> multipleUnreadEventsToLastMessage(unreadEventCount, uiTextResolver) + else -> multipleUnreadEventsToLastMessage(unreadEventCount) } } -private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount, uiTextResolver: UiTextResolver): UILastMessageContent { +private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount): UILastMessageContent { val unreadContentTexts = unreadEventCount .toSortedMap() .mapNotNull { type -> @@ -106,11 +103,8 @@ private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount } else { UILastMessageContent.TextMessage( MessageBody( - message = first, - markdownDocument = (first as? UIText.DynamicString)?.value?.toMarkdownDocument() + message = first ), - markdownPreview = first.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } } @@ -121,7 +115,7 @@ private fun String?.userUiText(isSelfMessage: Boolean): UIText = when { else -> UIText.StringResource(R.string.username_unavailable_label) } -@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") +@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "UNUSED_PARAMETER") fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastMessageContent { return when (content) { is WithUser -> { @@ -134,8 +128,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -150,8 +142,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -166,8 +156,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -182,8 +170,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } } @@ -198,8 +184,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -213,8 +197,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -228,8 +210,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -243,8 +223,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -252,8 +230,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -261,8 +237,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -291,11 +265,8 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.TextMessage( MessageBody( - message = previewMessageContent, - markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() - ), - previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), - uiTextResolver.localeTag() + message = previewMessageContent + ) ) } @@ -328,11 +299,8 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.TextMessage( MessageBody( - message = previewMessageContent, - markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() - ), - previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), - uiTextResolver.localeTag() + message = previewMessageContent + ) ) } @@ -343,11 +311,8 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.TextMessage( MessageBody( - message = previewMessageContent, - markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() - ), - previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), - uiTextResolver.localeTag() + message = previewMessageContent + ) ) } @@ -355,10 +320,7 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM is WithUser.Text -> UILastMessageContent.SenderWithMessage( sender = userUIText, message = (content as WithUser.Text).messageBody.let { UIText.DynamicString(it) }, - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", - markdownPreview = UIText.DynamicString((content as WithUser.Text).messageBody) - .toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" ) is WithUser.Composite -> { @@ -368,8 +330,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM sender = userUIText, message = text, separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", - markdownPreview = text.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -396,8 +356,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.SenderWithMessage( userUIText, message, - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -406,8 +364,6 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM userUIText, message, separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", - markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() ) } } @@ -438,11 +394,8 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM UILastMessageContent.TextMessage( MessageBody( - message = previewMessageContent, - markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() - ), - previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), - uiTextResolver.localeTag() + message = previewMessageContent + ) ) } @@ -475,17 +428,9 @@ fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastM is MessagePreviewContent.Draft -> UILastMessageContent.SenderWithMessage( UIText.StringResource(R.string.label_draft), (content as MessagePreviewContent.Draft).message.toUIText(), - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", - markdownPreview = (content as MessagePreviewContent.Draft).message.toUIText() - .toMarkdownPreviewOrNull(uiTextResolver), - markdownLocaleTag = uiTextResolver.localeTag() + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" ) Unknown -> UILastMessageContent.None } } - -private fun UIText.toMarkdownPreviewOrNull(uiTextResolver: UiTextResolver): MarkdownPreview? { - val resolved = uiTextResolver.resolve(this) - return resolved.toMarkdownDocument().getFirstInlines() -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index 2702d0a6df8..3ec0a227421 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -18,20 +18,22 @@ package com.wire.android.ui.home.conversations.usecase +import android.os.SystemClock import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map +import com.wire.android.appLogger import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem -import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig +import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase @@ -58,7 +60,6 @@ class GetConversationsFromSearchUseCase @Inject constructor( newActivitiesOnTop: Boolean = false, onlyInteractionEnabled: Boolean = false, conversationFilter: ConversationFilter = ConversationFilter.All, - playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None, useStrictMlsFilter: Boolean, ): Flow> { val selfUserTeamId = getSelfTeamId() @@ -100,18 +101,34 @@ class GetConversationsFromSearchUseCase @Inject constructor( } } .map { pagingData -> - pagingData.map { - it.toConversationItem( - userTypeMapper = userTypeMapper, - uiTextResolver = uiTextResolver, - searchQuery = searchQuery, - selfUserTeamId = selfUserTeamId, - playingAudioMessage = playingAudioMessage - ) - } + pagingData.mapConversationsWithTiming(selfUserTeamId) }.flowOn(dispatchers.io()) } + private fun PagingData.mapConversationsWithTiming( + selfUserTeamId: TeamId? + ): PagingData { + var pageIndex = 0 + var itemsInPage = 0 + var totalMappingNanos = 0L + return map { + val startNanos = SystemClock.elapsedRealtimeNanos() + val item = it.toConversationItem(userTypeMapper, uiTextResolver, selfUserTeamId) + totalMappingNanos += SystemClock.elapsedRealtimeNanos() - startNanos + itemsInPage++ + + val pageSize = if (pageIndex == 0) INITIAL_LOAD_SIZE else PAGE_SIZE + if (itemsInPage == pageSize) { + appLogger.d("$TAG: page=$pageIndex items=$itemsInPage totalMapperMs=${totalMappingNanos / NANOS_IN_MILLIS}") + pageIndex++ + itemsInPage = 0 + totalMappingNanos = 0L + } + + item + } + } + private fun staticPagingItems(conversations: List): PagingData { return PagingData.from( conversations, @@ -124,8 +141,10 @@ class GetConversationsFromSearchUseCase @Inject constructor( } private companion object { + const val TAG = "ConversationMapperTiming" const val PAGE_SIZE = 20 const val INITIAL_LOAD_SIZE = 40 const val PREFETCH_DISTANCE = 5 + const val NANOS_IN_MILLIS = 1_000_000 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 656e4ce9248..89479a47755 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -31,7 +31,9 @@ import com.wire.android.BuildConfig import com.wire.android.di.CurrentAccount import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem +import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail @@ -43,6 +45,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItemType import com.wire.android.ui.home.conversationslist.model.ConversationSection import com.wire.android.ui.home.conversationslist.model.ConversationsSource +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.Conversation @@ -82,6 +85,8 @@ import kotlinx.coroutines.launch interface ConversationListViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() val requestInProgress: Boolean get() = false + val isSelfUserUnderLegalHold: Flow get() = emptyFlow() + val playingAudio: Flow get() = emptyFlow() val conversationListState: ConversationListState get() = ConversationListState.Paginated(emptyFlow()) suspend fun refreshMissingMetadata() {} fun searchQueryChanged(searchQuery: String) {} @@ -129,7 +134,12 @@ class ConversationListViewModelImpl @AssistedInject constructor( override val requestInProgress: Boolean get() = _requestInProgress private val searchQueryFlow: MutableStateFlow = MutableStateFlow("") - private val isSelfUserUnderLegalHoldFlow = MutableSharedFlow(replay = 1) + private val isSelfUserUnderLegalHoldFlow = MutableStateFlow(false) + override val isSelfUserUnderLegalHold: Flow = isSelfUserUnderLegalHoldFlow + override val playingAudio: Flow = audioMessagePlayer.playingAudioMessageFlow + .map { it.toPlayingAudioInConversation() } + .distinctUntilChanged() + .flowOn(dispatcher.io()) private val containsNewActivitiesSection = when (conversationsSource) { ConversationsSource.MAIN, @@ -145,23 +155,17 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val conversationsPaginatedFlow: Flow> = searchQueryFlow .debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE } .onStart { emit("") } - .combine(isSelfUserUnderLegalHoldFlow.onStart { emit(false) }, ::Pair) .distinctUntilChanged() - .combine(audioMessagePlayer.playingAudioMessageFlow) { (searchQuery, isSelfUserUnderLegalHold), playingAudioMessage -> - Triple(searchQuery, isSelfUserUnderLegalHold, playingAudioMessage) - } - .flatMapLatest { (searchQuery, isSelfUserUnderLegalHold, playingAudioMessage) -> + .flatMapLatest { searchQuery -> getConversationsPaginated( searchQuery = searchQuery, fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, - playingAudioMessage = playingAudioMessage, useStrictMlsFilter = BuildConfig.USE_STRICT_MLS_FILTER, ).map { pagingData -> pagingData - .map { it.hideIndicatorForSelfUserUnderLegalHold(isSelfUserUnderLegalHold) } .insertSeparators { before, after -> when { // do not add separators if the list shouldn't show conversations grouped into different folders @@ -208,7 +212,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( observeLegalHoldStateForSelfUser() .map { it is LegalHoldStateForSelfUser.Enabled } .flowOn(dispatcher.io()) - .collect { isSelfUserUnderLegalHoldFlow.emit(it) } + .collect { isSelfUserUnderLegalHoldFlow.value = it } } } @@ -225,16 +229,13 @@ class ConversationListViewModelImpl @AssistedInject constructor( fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter() ), - isSelfUserUnderLegalHoldFlow, - audioMessagePlayer.playingAudioMessageFlow - ) { conversations, isSelfUserUnderLegalHold, playingAudioMessage -> + isSelfUserUnderLegalHoldFlow + ) { conversations, isSelfUserUnderLegalHold -> conversations.map { conversationDetails -> conversationDetails.toConversationItem( userTypeMapper = userTypeMapper, uiTextResolver = uiTextResolver, - searchQuery = searchQuery, - selfUserTeamId = selfTeamId, - playingAudioMessage = playingAudioMessage + selfUserTeamId = selfTeamId ).hideIndicatorForSelfUserUnderLegalHold(isSelfUserUnderLegalHold) } to searchQuery } @@ -416,3 +417,16 @@ private fun searchConversation(conversationDetails: List, sear is ConversationItem.PrivateConversation -> details.conversationInfo.name.contains(searchQuery, true) } } + +private fun PlayingAudioMessage.toPlayingAudioInConversation(): PlayingAudioInConversation? = + if (this is PlayingAudioMessage.Some) { + when { + state.isPlaying() -> PlayingAudioInConversation(conversationId, messageId, isPaused = false) + state.audioMediaPlayingState is AudioMediaPlayingState.Paused -> + PlayingAudioInConversation(conversationId, messageId, isPaused = true) + + else -> null + } + } else { + null + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 78a699c3aea..13376804cbe 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import com.ramcosta.composedestinations.generated.app.destinations.BrowseChannelsScreenDestination @@ -163,6 +164,9 @@ fun ConversationsScreenContent( conversationListViewModel.stopCurrentAudio() } } + val isSelfUserUnderLegalHold by conversationListViewModel.isSelfUserUnderLegalHold.collectAsStateWithLifecycle(false) + val playingAudio by conversationListViewModel.playingAudio.collectAsStateWithLifecycle(null) + val searchQuery = searchBarState.searchQueryTextState.text.toString() when (val state = conversationListViewModel.conversationListState) { is ConversationListState.Paginated -> { @@ -175,6 +179,9 @@ fun ConversationsScreenContent( lazyPagingItems.itemCount > 0 -> ConversationList( lazyPagingConversations = lazyPagingItems, lazyListState = lazyListState, + searchQuery = searchQuery, + isSelfUserUnderLegalHold = isSelfUserUnderLegalHold, + playingAudio = playingAudio, onOpenConversation = onOpenConversation, onEditConversation = onEditConversationItem, onOpenUserProfile = onOpenUserProfile, @@ -209,6 +216,9 @@ fun ConversationsScreenContent( hasConversations -> ConversationList( lazyListState = lazyListState, conversationListItems = state.conversations, + searchQuery = searchQuery, + isSelfUserUnderLegalHold = isSelfUserUnderLegalHold, + playingAudio = playingAudio, onOpenConversation = onOpenConversation, onEditConversation = onEditConversationItem, onOpenUserProfile = onOpenUserProfile, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 411f7cc42e0..80002eea9db 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -82,12 +82,19 @@ fun ConversationItemFactory( joinCall: (ConversationId) -> Unit = {}, onAudioPermissionPermanentlyDenied: () -> Unit = {}, onPlayPauseCurrentAudio: () -> Unit = { }, - onStopCurrentAudio: () -> Unit = {} + onStopCurrentAudio: () -> Unit = {}, + searchQuery: String = conversation.searchQuery, + isSelfUserUnderLegalHold: Boolean = false, + playingAudio: PlayingAudioInConversation? = conversation.playingAudio ) { val openConversationOptionDescription = stringResource(R.string.content_description_conversation_details_more_btn) val openUserProfileDescription = stringResource(R.string.content_description_open_user_profile_label) val acceptOrIgnoreDescription = stringResource(R.string.content_description_accept_or_ignore_connection_label) val openConversationDescription = stringResource(R.string.content_description_open_conversation_label) + val showLegalHoldIndicator = conversation.showLegalHoldIndicator && !isSelfUserUnderLegalHold + val playingAudioInConversation = playingAudio + ?.takeIf { it.conversationId == conversation.conversationId } + ?: conversation.playingAudio val onConversationItemClick = remember(conversation) { when (val lastEvent = conversation.lastMessageContent) { is UILastMessageContent.Connection -> { @@ -151,6 +158,9 @@ fun ConversationItemFactory( joinCall(conversation.conversationId) }, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + searchQuery = searchQuery, + showLegalHoldIndicator = showLegalHoldIndicator, + playingAudio = playingAudioInConversation, onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, onStopCurrentAudio = onStopCurrentAudio ) @@ -168,6 +178,9 @@ private fun GeneralConversationItem( modifier: Modifier = Modifier, selectOnRadioGroup: () -> Unit = {}, subTitle: @Composable () -> Unit = {}, + searchQuery: String = conversation.searchQuery, + showLegalHoldIndicator: Boolean = conversation.showLegalHoldIndicator, + playingAudio: PlayingAudioInConversation? = conversation.playingAudio, onPlayPauseCurrentAudio: () -> Unit = { }, onStopCurrentAudio: () -> Unit = {} ) { @@ -197,7 +210,7 @@ private fun GeneralConversationItem( title = { ConversationTitle( name = groupName.ifEmpty { stringResource(id = R.string.member_name_deleted_label) }, - showLegalHoldIndicator = conversation.showLegalHoldIndicator, + showLegalHoldIndicator = showLegalHoldIndicator, searchQuery = searchQuery ) }, @@ -210,9 +223,9 @@ private fun GeneralConversationItem( buttonClick = onJoinCallClick, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, ) - } else if (conversation.playingAudio != null) { + } else if (playingAudio != null) { AudioControlButtons( - playingAudio = conversation.playingAudio!!, + playingAudio = playingAudio, onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, onStopCurrentAudio = onStopCurrentAudio ) @@ -251,7 +264,7 @@ private fun GeneralConversationItem( }, title = { UserLabel( - userInfoLabel = toUserInfoLabel(), + userInfoLabel = toUserInfoLabel(showLegalHoldIndicator), searchQuery = searchQuery ) }, @@ -259,9 +272,9 @@ private fun GeneralConversationItem( clickable = onConversationItemClick, actions = { if (!isSelectable) { - if (conversation.playingAudio != null) { + if (playingAudio != null) { AudioControlButtons( - playingAudio = conversation.playingAudio, + playingAudio = playingAudio, onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, onStopCurrentAudio = onStopCurrentAudio ) @@ -293,7 +306,7 @@ private fun GeneralConversationItem( }, title = { UserLabel( - userInfoLabel = toUserInfoLabel(), + userInfoLabel = toUserInfoLabel(showLegalHoldIndicator), searchQuery = searchQuery ) }, @@ -683,7 +696,7 @@ fun PreviewPrivateConversationItemWithPlayingAudio() = WireTheme { proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, folder = null, - playingAudio = PlayingAudioInConversation("some_id", true) + playingAudio = PlayingAudioInConversation(QualifiedID("value", "domain"), "some_id", true) ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index d38a9eec1a4..3adab0c1362 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -61,6 +61,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItemType import com.wire.android.ui.home.conversationslist.model.ConversationSection +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes @@ -84,6 +85,9 @@ fun ConversationList( lazyListState: LazyListState = rememberLazyListState(), isSelectableList: Boolean = false, selectedConversations: List = emptyList(), + searchQuery: String = "", + isSelfUserUnderLegalHold: Boolean = false, + playingAudio: PlayingAudioInConversation? = null, onOpenConversation: (ConversationItem) -> Unit = {}, onEditConversation: (ConversationItem) -> Unit = {}, onOpenUserProfile: (UserId) -> Unit = {}, @@ -143,6 +147,9 @@ fun ConversationList( conversation = item, isSelectableItem = isSelectableList, isChecked = selectedConversations.contains(item.conversationId), + searchQuery = searchQuery, + isSelfUserUnderLegalHold = isSelfUserUnderLegalHold, + playingAudio = playingAudio, onConversationSelectedOnRadioGroup = { onConversationSelectedOnRadioGroup(item) }, openConversation = onOpenConversation, openMenu = onEditConversation, @@ -209,6 +216,9 @@ fun ConversationList( lazyListState: LazyListState = rememberLazyListState(), isSelectableList: Boolean = false, selectedConversations: List = emptyList(), + searchQuery: String = "", + isSelfUserUnderLegalHold: Boolean = false, + playingAudio: PlayingAudioInConversation? = null, onOpenConversation: (ConversationItem) -> Unit = {}, onEditConversation: (ConversationItem) -> Unit = {}, onOpenUserProfile: (UserId) -> Unit = {}, @@ -237,6 +247,9 @@ fun ConversationList( conversation = generalConversation, isSelectableItem = isSelectableList, isChecked = selectedConversations.contains(generalConversation), + searchQuery = searchQuery, + isSelfUserUnderLegalHold = isSelfUserUnderLegalHold, + playingAudio = playingAudio, onConversationSelectedOnRadioGroup = { onConversationSelectedOnRadioGroup(generalConversation.conversationId) }, openConversation = onOpenConversation, openMenu = onEditConversation, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index f4a0068e393..8943903c52b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -79,7 +79,7 @@ sealed interface ConversationItem : ConversationItemType { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", - override val playingAudio: PlayingAudioInConversation? + override val playingAudio: PlayingAudioInConversation? = null ) : Group @Serializable @@ -102,7 +102,7 @@ sealed interface ConversationItem : ConversationItemType { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", - override val playingAudio: PlayingAudioInConversation?, + override val playingAudio: PlayingAudioInConversation? = null, val isPrivate: Boolean, ) : Group } @@ -127,7 +127,7 @@ sealed interface ConversationItem : ConversationItemType { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", - override val playingAudio: PlayingAudioInConversation? + override val playingAudio: PlayingAudioInConversation? = null ) : ConversationItem @Serializable @@ -161,6 +161,7 @@ data class ConversationInfo( @Serializable data class PlayingAudioInConversation( + val conversationId: ConversationId, val messageId: String, val isPaused: Boolean ) @@ -179,7 +180,9 @@ val OtherUser.BlockState: BlockingState else -> BlockingState.NOT_BLOCKED } -fun ConversationItem.PrivateConversation.toUserInfoLabel() = +fun ConversationItem.PrivateConversation.toUserInfoLabel( + showLegalHoldIndicator: Boolean = this.showLegalHoldIndicator +) = UserInfoLabel( labelName = conversationInfo.name, showLegalHoldIndicator = showLegalHoldIndicator, @@ -189,7 +192,9 @@ fun ConversationItem.PrivateConversation.toUserInfoLabel() = proteusVerificationStatus = proteusVerificationStatus ) -fun ConversationItem.ConnectionConversation.toUserInfoLabel() = +fun ConversationItem.ConnectionConversation.toUserInfoLabel( + showLegalHoldIndicator: Boolean = this.showLegalHoldIndicator +) = UserInfoLabel( labelName = conversationInfo.name, showLegalHoldIndicator = showLegalHoldIndicator, diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index d6c3e9511a1..0fea48d5284 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -336,6 +336,7 @@ fun ImportMediaRegularContent( ImportMediaContent( state = this, internalPadding = internalPadding, + searchQuery = searchQueryTextState.text.toString(), onConversationClicked = onConversationClicked, lazyListState = lazyListState, ) @@ -542,6 +543,7 @@ fun ImportMediaTopBarContent( private fun ImportMediaContent( state: ImportMediaAuthenticatedState, internalPadding: PaddingValues, + searchQuery: String, onConversationClicked: (conversationItem: ConversationItem) -> Unit, lazyListState: LazyListState = rememberLazyListState(), ) { @@ -557,6 +559,7 @@ private fun ImportMediaContent( lazyPagingConversations = lazyPagingConversations, selectedConversations = state.selectedConversationItem, isSelectableList = true, + searchQuery = searchQuery, onConversationSelectedOnRadioGroup = onConversationClicked, onOpenConversation = onConversationClicked, onEditConversation = {}, diff --git a/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt index 13bf7ad62ef..b4c0f2e7627 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.test.runTest import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.util.ui.UiTextResolver +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -149,6 +150,22 @@ class MessagePreviewContentMapperTest { result.value shouldBeEqualTo lastMessage } + @Test + fun givenLastTextMessageContainsMarkdown_whenMappingToUIPreview_thenMarkdownShouldNotBeParsed() = runTest { + val lastMessage = "**hello**" + val messagePreview = TestMessage.PREVIEW.copy( + content = MessagePreviewContent.WithUser.Text("admin", lastMessage), + ) + + val senderWithMessage = messagePreview.toUIPreview(emptyMap(), uiTextResolver) + .shouldBeInstanceOf() + val result = senderWithMessage.message.shouldBeInstanceOf() + + result.value shouldBeEqualTo lastMessage + assertNull(senderWithMessage.markdownPreview) + assertNull(senderWithMessage.markdownLocaleTag) + } + @Test fun givenLastAssetAudioConversationMessage_whenMappingToUILastMessageContent_thenCorrectContentShouldBeReturned() = runTest { val messagePreview = TestMessage.PREVIEW.copy( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index fe0488b6a6f..90e0c05d5f8 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -101,7 +101,6 @@ class GetConversationsFromSearchUseCaseTest { // Then result.forEachIndexed { index, conversationItem -> assertEquals(conversationsList[index].conversationDetails.conversation.id, conversationItem.conversationId) - assertEquals(arrangement.queryConfig.searchQuery, conversationItem.searchQuery) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index 1e355fcd832..0bf67c324fe 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -22,7 +22,6 @@ package com.wire.android.ui.home.conversationslist import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingData -import androidx.paging.testing.asSnapshot import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider @@ -31,6 +30,8 @@ import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestConversationItem import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper +import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase @@ -145,62 +146,39 @@ class ConversationListViewModelTest { } @Test - fun `given self user is under legal hold, when collecting conversations, then hide LH indicators`() = + fun `given self user is under legal hold, when observing row state, then expose hidden LH indicators state`() = runTest(dispatcherProvider.main()) { // Given - val conversations = listOf( - TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), - TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), - TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), - TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), - TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), - TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), - ).associateBy { it.conversationId } val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) - .withConversationsPaginated(conversations.values.toList()) .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Enabled) .arrange() advanceUntilIdle() // When - (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() - .filterIsInstance() - .forEach { - // Then - assertEquals(false, it.showLegalHoldIndicator) // self user is under LH so hide LH indicators next to conversations - } + val isSelfUserUnderLegalHold = conversationListViewModel.isSelfUserUnderLegalHold.first() + + // Then + assertEquals(true, isSelfUserUnderLegalHold) } @Test - fun `given self user is not under legal hold, when collecting conversations, then show LH indicator when conversation is under LH`() = + fun `given self user is not under legal hold, when observing row state, then expose visible LH indicators state`() = runTest(dispatcherProvider.main()) { // Given - val conversations = listOf( - TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), - TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), - TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), - TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), - TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), - TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), - ).associateBy { it.conversationId } val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) - .withConversationsPaginated(conversations.values.toList()) .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) .arrange() advanceUntilIdle() // When - (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() - .filterIsInstance() - .forEach { - // Then - val expected = conversations[it.conversationId]!!.showLegalHoldIndicator // show indicator when conversation is under LH - assertEquals(expected, it.showLegalHoldIndicator) - } + val isSelfUserUnderLegalHold = conversationListViewModel.isSelfUserUnderLegalHold.first() + + // Then + assertEquals(false, isSelfUserUnderLegalHold) } @Test - fun `given cached PagingData, when self user legal hold changes, then should call paginated use case again`() = + fun `given cached PagingData, when self user legal hold changes, then should not call paginated use case again`() = runTest(dispatcherProvider.main()) { // given val conversations = listOf( @@ -229,8 +207,41 @@ class ConversationListViewModelTest { selfUserLegalHoldStateFlow.emit(LegalHoldStateForSelfUser.Enabled) advanceUntilIdle() - // then use case should be called again (in total 2 executions) to create new PagingData - coVerify(exactly = 2) { + // then use case should not be called again because LH is derived per visible row + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(any(), any(), any(), any(), useStrictMlsFilter = any()) + } + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given cached PagingData, when playing audio changes, then should not call paginated use case again`() = + runTest(dispatcherProvider.main()) { + // given + val audioMessageFlow = MutableSharedFlow() + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withPlayingAudioMessageFlow(audioMessageFlow) + .arrange() + advanceUntilIdle() + + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(any(), any(), any(), any(), useStrictMlsFilter = any()) + } + + audioMessageFlow.emit( + PlayingAudioMessage.Some( + conversationId = ConversationId("conn_1", ""), + messageId = "message_id", + authorName = UIText.DynamicString("Author"), + state = AudioState(AudioMediaPlayingState.Playing, 0, AudioState.TotalTimeInMs.NotKnown) + ) + ) + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.getConversationsPaginated(any(), any(), any(), any(), useStrictMlsFilter = any()) } @@ -348,6 +359,10 @@ class ConversationListViewModelTest { coEvery { observeLegalHoldStateForSelfUserUseCase() } returns legalHoldStateForSelfUserFlow } + fun withPlayingAudioMessageFlow(playingAudioMessageFlow: Flow) = apply { + every { audioMessagePlayer.playingAudioMessageFlow } returns playingAudioMessageFlow + } + fun arrange() = this to ConversationListViewModelImpl( conversationsSource = conversationsSource, dispatcher = dispatcherProvider, From 47174d436917f3a8ff4461604c3c5ae9b61f86d5 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 21 May 2026 11:56:26 +0200 Subject: [PATCH 2/2] refactor: improve markdown formatting and audio handling in conversation items --- .../common/LastMessageSubtitle.kt | 21 +++-- .../LightweightMarkdownPreviewParser.kt | 76 ++++++++++++++++++ .../android/ui/markdown/MarkdownParser.kt | 10 +++ .../android/ui/markdown/MarkdownHelperTest.kt | 80 +++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/markdown/LightweightMarkdownPreviewParser.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt index 456c169c04b..550599322af 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.conversationslist.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.style.TextOverflow import com.wire.android.ui.markdown.MarkdownConstants @@ -29,6 +30,8 @@ import com.wire.android.ui.markdown.MarkdownPreview import com.wire.android.ui.markdown.MarkdownNode import com.wire.android.ui.markdown.MessageColors import com.wire.android.ui.markdown.NodeData +import com.wire.android.ui.markdown.previewMarkdownSource +import com.wire.android.ui.markdown.toLightweightMarkdownPreviewFromSource import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -83,8 +86,13 @@ private fun LastMessageMarkdown( val locales = LocalConfiguration.current.locales val currentLocaleTag = if (locales.isEmpty) "" else locales[0].toLanguageTag() val shouldUsePreview = markdownPreview != null && (markdownLocaleTag == null || markdownLocaleTag == currentLocaleTag) + val resolvedMarkdownPreview = if (shouldUsePreview) { + markdownPreview + } else { + rememberLightweightMarkdownPreview(text = text) + } - if (shouldUsePreview) { + if (resolvedMarkdownPreview != null) { val leadingInlines = if (leadingText.isBlank()) { persistentListOf() } else { @@ -94,10 +102,7 @@ private fun LastMessageMarkdown( ) ) } - MarkdownInline( - inlines = leadingInlines.plus(markdownPreview.children), - nodeData = nodeData - ) + MarkdownInline(inlines = leadingInlines.plus(resolvedMarkdownPreview.children), nodeData = nodeData) } else { Text( text = leadingText.replace(MarkdownConstants.NON_BREAKING_SPACE, " ") + @@ -109,3 +114,9 @@ private fun LastMessageMarkdown( ) } } + +@Composable +private fun rememberLightweightMarkdownPreview(text: String): MarkdownPreview? { + val source = remember(text) { text.previewMarkdownSource() } + return remember(source) { source.toLightweightMarkdownPreviewFromSource() } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/LightweightMarkdownPreviewParser.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/LightweightMarkdownPreviewParser.kt new file mode 100644 index 00000000000..4c3fe416607 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/LightweightMarkdownPreviewParser.kt @@ -0,0 +1,76 @@ +/* + * Wire + * Copyright (C) 2026 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 + +private const val MAX_PREVIEW_MARKDOWN_CHARS = 200 +private val MARKDOWN_PREVIEW_INLINE_TRIGGERS = charArrayOf('*', '_', '`', '[', '!', '~') + +internal fun String.previewMarkdownSource(): String { + val newlineIndex = indexOf('\n').takeIf { it >= 0 } ?: length + val previewEnd = minOf(newlineIndex, MAX_PREVIEW_MARKDOWN_CHARS) + return substring(0, previewEnd).replace(MarkdownConstants.NON_BREAKING_SPACE, " ") +} + +internal fun String.toLightweightMarkdownPreview(): MarkdownPreview? { + return previewMarkdownSource().toLightweightMarkdownPreviewFromSource() +} + +internal fun String.toLightweightMarkdownPreviewFromSource(): MarkdownPreview? { + if (isBlank() || !hasPreviewMarkdownTrigger()) { + return null + } + return MarkdownParser.parsePreview(this) + ?.takeIf { it.children.hasVisiblePreviewText() } +} + +internal fun String.hasPreviewMarkdownTrigger(): Boolean = + indexOfAny(MARKDOWN_PREVIEW_INLINE_TRIGGERS) >= 0 || firstNonWhitespaceCharCanStartMarkdown() + +private fun String.firstNonWhitespaceCharCanStartMarkdown(): Boolean { + val firstContentIndex = indexOfFirst { !it.isWhitespace() } + if (firstContentIndex == -1) return false + return when (this[firstContentIndex]) { + '#', '>', '-', '+', '|' -> true + in '0'..'9' -> isOrderedListMarker(firstContentIndex) + else -> false + } +} + +private fun String.isOrderedListMarker(startIndex: Int): Boolean { + var index = startIndex + while (index < length && this[index].isDigit()) { + index++ + } + return index > startIndex && index + 1 < length && this[index] in ".)" && this[index + 1].isWhitespace() +} + +private fun List.hasVisiblePreviewText(): Boolean { + return any { inline -> + when (inline) { + is MarkdownNode.Inline.Text -> inline.literal.isNotBlank() + is MarkdownNode.Inline.Code -> inline.literal.isNotBlank() + is MarkdownNode.Inline.Break -> false + is MarkdownNode.Inline.Emphasis -> inline.children.hasVisiblePreviewText() + is MarkdownNode.Inline.StrongEmphasis -> inline.children.hasVisiblePreviewText() + is MarkdownNode.Inline.Strikethrough -> inline.children.hasVisiblePreviewText() + is MarkdownNode.Inline.Link -> inline.children.hasVisiblePreviewText() || inline.destination.isNotBlank() + is MarkdownNode.Inline.Image -> inline.children.hasVisiblePreviewText() || inline.destination.isNotBlank() + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt index 5115918e3fd..f093f1efe0e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt @@ -28,6 +28,10 @@ object MarkdownParser { .includeSourceSpans(IncludeSourceSpans.BLOCKS) .build() + private val previewParser = Parser.builder() + .extensions(MarkdownConstants.supportedExtensions) + .build() + // We preserve blank lines across *all* block types by using source spans: // CommonMark collapses empty lines into block boundaries, so we count // blank input lines between top-level blocks and insert spacer paragraphs @@ -58,6 +62,12 @@ object MarkdownParser { ) } + fun parsePreview(text: String): MarkdownPreview? { + val documentNode = previewParser.parse(text) as Document + val firstBlock = documentNode.firstChild ?: return null + return (firstBlock.toContent() as? MarkdownNode.Block)?.getFirstInlines() + } + private fun collectTopLevelBlocks(document: Document): List { // Collect direct children (top-level blocks) because source spans are per block. val blocks = mutableListOf() 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 index f194650685c..5157f9d4f34 100644 --- a/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt @@ -135,6 +135,86 @@ class MarkdownHelperTest { assertEquals("test", (lastParagraph.children[0] as MarkdownNode.Inline.Text).literal) } + @Test + fun `given plain text, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "plain conversation preview".toLightweightMarkdownPreview() + + assertNull(result) + } + + @Test + fun `given markdown text, when toLightweightMarkdownPreview is called, then it should use common markdown nodes`() { + val result = "hello **wire**".toLightweightMarkdownPreview() + + assertNotNull(result) + assertEquals(2, result!!.children.size) + assertTrue(result.children[0] is MarkdownNode.Inline.Text) + assertTrue(result.children[1] is MarkdownNode.Inline.StrongEmphasis) + } + + @Test + fun `given inline code, when toLightweightMarkdownPreview is called, then it should keep code formatting`() { + val result = "hello `wire`".toLightweightMarkdownPreview() + + assertNotNull(result) + assertEquals(2, result!!.children.size) + assertTrue(result.children[0] is MarkdownNode.Inline.Text) + assertTrue(result.children[1] is MarkdownNode.Inline.Code) + assertEquals("wire", (result.children[1] as MarkdownNode.Inline.Code).literal) + } + + @Test + fun `given markdown across multiple lines, when toLightweightMarkdownPreview is called, then it should parse only first line`() { + val result = "first **line**\nsecond **line**".toLightweightMarkdownPreview() + + assertNotNull(result) + assertEquals("first ", (result!!.children[0] as MarkdownNode.Inline.Text).literal) + assertEquals("line", ((result.children[1] as MarkdownNode.Inline.StrongEmphasis).children[0] as MarkdownNode.Inline.Text).literal) + } + + @Test + fun `given fenced code with content after first line, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "```kotlin\nval value = 1\n```".toLightweightMarkdownPreview() + + assertNull(result) + } + + @Test + fun `given tilde fenced code with content after first line, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "~~~kotlin\nval value = 1\n~~~".toLightweightMarkdownPreview() + + assertNull(result) + } + + @Test + fun `given empty list marker on first line, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "-\nlist item text".toLightweightMarkdownPreview() + + assertNull(result) + } + + @Test + fun `given empty ordered list marker on first line, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "1.\nlist item text".toLightweightMarkdownPreview() + + assertNull(result) + } + + @Test + fun `given table markdown, when toLightweightMarkdownPreview is called, then it should keep only first row text`() { + val result = "| name | value |\n| --- | --- |\n| Wire | Android |".toLightweightMarkdownPreview() + + assertNotNull(result) + assertEquals("| name | value |", (result!!.children[0] as MarkdownNode.Inline.Text).literal) + } + + @Test + fun `given setext heading marker on second line, when toLightweightMarkdownPreview is called, then it should return null`() { + val result = "Heading\n---".toLightweightMarkdownPreview() + + assertNull(result) + } + @Test fun `given bullet list node, when toContent is called, then it should return Block BulletList`() { val bulletListNode = BulletList()