From d0a7447714f48c439529d88be06610eb437cf570 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 21 May 2026 12:38:22 +0200 Subject: [PATCH 1/2] refactor: Improve message list loading performance Avoid eagerly parsing markdown documents when mapping text messages, cache mention display mapping across recompositions, and lazily compute formatted message timestamps. Add coverage to ensure markdown parsing is deferred to the UI layer. --- .../mapper/RegularMessageContentMapper.kt | 17 ++--------------- .../ui/home/conversations/model/MessageTypes.kt | 12 +++++++++--- .../ui/home/conversations/model/UIMessage.kt | 14 ++++++++++++-- .../mapper/RegularMessageContentMapperTest.kt | 13 +++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) 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 fe5457f6e19..9724ed2d2f3 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt @@ -28,8 +28,6 @@ import com.wire.android.ui.home.conversations.model.MessageButton import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.home.conversations.model.messagetypes.image.VisualMediaParams -import com.wire.android.ui.markdown.toMarkdownDocument -import com.wire.android.ui.markdown.toMarkdownTextWithMentions import com.wire.android.ui.theme.Accent import com.wire.android.util.time.ISOFormatter import com.wire.android.util.ui.UIText @@ -110,11 +108,7 @@ class RegularMessageMapper @Inject constructor( MessageBody( message = UIText.DynamicString(textContent.value, content.textContent?.mentions.orEmpty()), - quotedMessage = quotedMessage, - markdownDocument = UIText.DynamicString( - textContent.value, - content.textContent?.mentions.orEmpty() - ).toMarkdownTextWithMentions().second.toMarkdownDocument() + quotedMessage = quotedMessage ) } @@ -199,16 +193,9 @@ class RegularMessageMapper @Inject constructor( else -> UIText.StringResource(R.string.sent_a_message_with_unknown_content) } - val markdownDocument = if (uiText is UIText.DynamicString) { - uiText.toMarkdownTextWithMentions().second.toMarkdownDocument() - } else { - null - } - return MessageBody( message = uiText, - quotedMessage = quotedMessage, - markdownDocument = markdownDocument + quotedMessage = quotedMessage ).let { messageBody -> UIMessageContent.TextMessage( messageBody = messageBody, 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 d53987364d2..d1150693e72 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.DpSize @@ -106,9 +107,14 @@ internal fun MessageBody( clickable: Boolean = true, messageStyle: MessageStyle = MessageStyle.NORMAL ) { - val (displayMentions, text) = messageBody?.message?.let { - mapToDisplayMentions(it, LocalContext.current.resources) - } ?: Pair(emptyList(), null) + val resources = LocalContext.current.resources + val configuration = LocalConfiguration.current + val message = messageBody?.message + val (displayMentions, text) = remember(message, configuration) { + message?.let { + mapToDisplayMentions(it, resources) + } ?: Pair(emptyList(), null) + } val color = when (messageStyle) { MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary 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 41b28e31c6a..20a3d7bcbd7 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 @@ -644,8 +644,18 @@ enum class MessageSource { @Serializable data class MessageTime(val instant: Instant) { - val utcISO: String = instant.toIsoDateTimeString() - val formattedDate: String = utcISO.uiMessageDateTime() ?: "" + @Transient + private val utcISOValue: Lazy = lazy(LazyThreadSafetyMode.PUBLICATION) { + instant.toIsoDateTimeString() + } + + @Transient + private val formattedDateValue: Lazy = lazy(LazyThreadSafetyMode.PUBLICATION) { + utcISO.uiMessageDateTime() ?: "" + } + + val utcISO: String get() = utcISOValue.value + val formattedDate: String get() = formattedDateValue.value fun getFormattedDateGroup(now: Long): MessageDateTimeGroup? = utcISO.groupedUIMessageDateTime(now = now) fun shouldDisplayDatesDifferenceDivider(previousDate: String): Boolean = utcISO.shouldDisplayDatesDifferenceDivider(previousDate = previousDate) diff --git a/app/src/test/kotlin/com/wire/android/mapper/RegularMessageContentMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/RegularMessageContentMapperTest.kt index 408851eb17a..7e425142125 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/RegularMessageContentMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/RegularMessageContentMapperTest.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.test.runTest import okio.Path import okio.Path.Companion.toPath import okio.buffer +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -83,6 +84,18 @@ class RegularMessageContentMapperTest { } } + @Test + fun givenTextContent_whenMappingToTextMessageContent_thenMarkdownDocumentShouldBeParsedByUi() = runTest { + // Given + val (_, mapper) = Arrangement().arrange() + + // When + val result = mapper.toText(TestConversation.ID, TestMessage.TEXT_MESSAGE.content, userMembers, DeliveryStatus.CompleteDelivery) + + // Then + assertNull(result.messageBody.markdownDocument) + } + @Test fun givenAssetContent_whenMappingToUIMessageContent_thenCorrectValuesShouldBeReturned() = runTest { // Given From 3561303bfc46f544f6512b98cdeeaf78783af214 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 21 May 2026 12:48:46 +0200 Subject: [PATCH 2/2] refactor: Optimize scoped message view model keys Limit audio and asset local path scoped view model keys to the visible message window plus a small prefetch buffer instead of all loaded paging items. Keep the currently playing audio message in scope while playback is active. --- .../home/conversations/ConversationScreen.kt | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) 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 507195c12a8..39ef378296e 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 @@ -230,6 +230,8 @@ import com.wire.android.ui.common.R as commonR */ private const val MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS = 5 +private const val SCOPED_VIEW_MODEL_PREFETCH_WINDOW = 3 + /** * The maximum number of participants to start a call without showing a confirmation dialog. */ @@ -1349,11 +1351,22 @@ fun MessageList( } } - val audioMessageKeysInScope = remember(lazyPagingMessages.itemSnapshotList.items) { - lazyPagingMessages.itemSnapshotList.items.mapNotNull { it.audioMessageScopedKeyOrNull() }.distinct() + val scopedMessages by remember(lazyListState, lazyPagingMessages) { + derivedStateOf { + lazyPagingMessages.peekVisibleWindowItems(lazyListState, SCOPED_VIEW_MODEL_PREFETCH_WINDOW) + } + } + val playingAudioMessageKey = (playingAudioMessage as? PlayingAudioMessage.Some)?.let { + AudioMessageArgs(it.conversationId, it.messageId).key } - val assetLocalPathKeysInScope = remember(lazyPagingMessages.itemSnapshotList.items) { - lazyPagingMessages.itemSnapshotList.items + val audioMessageKeysInScope = remember(scopedMessages, playingAudioMessageKey) { + buildList { + scopedMessages.mapNotNullTo(this) { it.audioMessageScopedKeyOrNull() } + playingAudioMessageKey?.let(::add) + }.distinct() + } + val assetLocalPathKeysInScope = remember(scopedMessages) { + scopedMessages .flatMap { it.assetLocalPathScopedKeys() } .distinct() } @@ -1651,6 +1664,27 @@ private fun BoxScope.ScrollDateOverlay( private fun LazyPagingItems.peekOrNull(index: Int): UIMessage? = if (index in 0 until itemCount) peek(index) else null +private fun LazyPagingItems.peekVisibleWindowItems( + lazyListState: LazyListState, + prefetchWindow: Int +): List { + val visibleItems = lazyListState.layoutInfo.visibleItemsInfo + return if (itemCount == 0 || visibleItems.isEmpty()) { + emptyList() + } else { + val firstVisibleIndex = visibleItems.minOf { it.index } + val lastVisibleIndex = visibleItems.maxOf { it.index } + val firstIndex = (firstVisibleIndex - prefetchWindow).coerceAtLeast(0) + val lastIndex = (lastVisibleIndex + prefetchWindow).coerceAtMost(itemCount - 1) + + if (firstIndex > lastIndex) { + emptyList() + } else { + (firstIndex..lastIndex).mapNotNull { index -> peekOrNull(index) } + } + } +} + @Composable private fun MessageGroupDateTime( now: Long,