From d8ac3f7d195fb8374f987bfc6a344eb74d372bf1 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 1 Jun 2026 16:19:09 +0200 Subject: [PATCH 01/18] feat: base for link previews --- .../wire/android/di/accountScoped/MessageModule.kt | 6 ++++++ .../sendmessage/SendMessageViewModel.kt | 13 ++++++++++++- kalium | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index b0840f9b021..88c46a2046d 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -33,6 +33,7 @@ import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.FetchOlderNomadMessagesByConversationUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase +import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import com.wire.kalium.logic.feature.message.GetNotificationsUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesByConversationUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase @@ -114,6 +115,11 @@ class MessageModule { @Provides fun provideSendTextMessageUseCase(messageScope: MessageScope): SendTextMessageUseCase = messageScope.sendTextMessage + @ViewModelScoped + @Provides + fun provideGenerateLinkPreviewUseCase(messageScope: MessageScope): GenerateLinkPreviewUseCase = + messageScope.generateLinkPreview + @ViewModelScoped @Provides fun provideSendEditTextMessageUseCase(messageScope: MessageScope): SendEditTextMessageUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 8fdee9af436..1d5455734ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -50,6 +50,7 @@ import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.getVideoMetaData import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AttachmentType @@ -75,6 +76,7 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -109,7 +111,8 @@ class SendMessageViewModel @Inject constructor( private val removeMessageDraft: RemoveMessageDraftUseCase, private val analyticsManager: AnonymousAnalyticsManager, private val isWireCellsEnabledForConversation: IsWireCellsEnabledForConversationUseCase, - private val sharedState: MessageSharedState + private val sharedState: MessageSharedState, + private val generateLinkPreview: GenerateLinkPreviewUseCase ) : ViewModel() { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -266,9 +269,17 @@ class SendMessageViewModel @Inject constructor( removeMessageDraft(messageBundle.conversationId) sendTypingEvent(messageBundle.conversationId, TypingIndicatorMode.STOPPED) with(messageBundle) { + // Generate link preview if present + val linkPreviewResult = generateLinkPreview( + text = message, + mentions = mentions.map { it.intoMessageMention() } + ) + val linkPreviews = linkPreviewResult.getOrNull()?.let { listOf(it) } ?: emptyList() + sendTextMessage( conversationId = conversationId, text = message, + linkPreviews = linkPreviews, mentions = mentions.map { it.intoMessageMention() }, quotedMessageId = quotedMessageId ).toEither() diff --git a/kalium b/kalium index 4f92bdb540c..36e681d6f6b 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 4f92bdb540cdab5fc8317f58507e2d5fb3d9e02c +Subproject commit 36e681d6f6bc4130d84d5be2b56002a9b5950d47 From 8353d6f23a53375ee689e46dd5628753ce7dccd5 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 1 Jun 2026 17:53:05 +0200 Subject: [PATCH 02/18] feat: base for link previews, mapping to ui --- .../com/wire/android/mapper/MessageMapper.kt | 3 +- .../messages/item/MessageContentAndStatus.kt | 9 ++- .../home/conversations/model/MessageTypes.kt | 11 ++- .../ui/home/conversations/model/UIMessage.kt | 2 + .../sendmessage/SendMessageViewModel.kt | 16 +++++ .../home/messagecomposer/LinkPreviewCard.kt | 69 +++++++++++++++++++ .../SendMessageViewModelArrangement.kt | 7 +- 7 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index dc5afd9738d..3796b62c41f 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -108,7 +108,8 @@ class MessageMapper @Inject constructor( source = if (sender is SelfUser) MessageSource.Self else MessageSource.OtherUser, header = provideMessageHeader(sender, message), messageFooter = footer, - userAvatarData = getUserAvatarData(sender) + userAvatarData = getUserAvatarData(sender), + linkPreviews = if (message is Message.Regular) message.linkPreviews else emptyList() ) is UIMessageContent.SystemMessage -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 59cd7b68bf0..3fe182b6d80 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -248,7 +248,8 @@ private fun MessageContent( messageId = message.header.messageId, onLinkClick = onLinkClick, messageStyle = messageStyle, - accent = accent + accent = accent, + linkPreviews = message.linkPreviews ) } } @@ -289,7 +290,8 @@ private fun MessageContent( messageId = message.header.messageId, onLinkClick = onLinkClick, messageStyle = messageStyle, - accent = accent + accent = accent, + linkPreviews = message.linkPreviews ) } } @@ -412,7 +414,8 @@ private fun MessageContent( messageId = message.header.messageId, onLinkClick = onLinkClick, messageStyle = messageStyle, - accent = accent + accent = accent, + linkPreviews = message.linkPreviews ) Spacer(modifier = Modifier.height(dimensions().spacing8x)) } 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 38be6c7d91b..d837f56c86c 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.DpSize +import com.wire.android.appLogger import com.wire.android.di.wireViewModelScoped import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset @@ -88,6 +89,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 com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import kotlinx.collections.immutable.PersistentList import okio.Path @@ -104,7 +106,8 @@ internal fun MessageBody( onLinkClick: (String) -> Unit, searchQuery: String = "", clickable: Boolean = true, - messageStyle: MessageStyle = MessageStyle.NORMAL + messageStyle: MessageStyle = MessageStyle.NORMAL, + linkPreviews: List = emptyList() ) { val (displayMentions, text) = messageBody?.message?.let { mapToDisplayMentions(it, LocalContext.current.resources) @@ -151,6 +154,12 @@ internal fun MessageBody( clickable ) } + + appLogger.d("ym. linkpreview is : $linkPreviews") + linkPreviews.firstOrNull()?.let { preview -> + VerticalSpace.x4() + com.wire.android.ui.home.messagecomposer.LinkPreviewCard(preview = preview) + } buttonList?.also { VerticalSpace.x4() MessageButtonsContent( 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 b0e9ceb13e4..3a97b8ca64c 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 @@ -76,6 +76,8 @@ sealed interface UIMessage { val userAvatarData: UserAvatarData, override val messageContent: UIMessageContent.Regular?, val messageFooter: MessageFooter, + @Transient + val linkPreviews: List = emptyList(), ) : UIMessage { val isDeleted: Boolean = header.messageStatus.isDeleted override val sendingFailed: Boolean = header.messageStatus.flowStatus is MessageFlowStatus.Failure.Send diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 1d5455734ff..ce6f0bbc35a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -77,6 +77,8 @@ import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview +import com.wire.kalium.logic.data.message.mention.MessageMention import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -129,6 +131,9 @@ class SendMessageViewModel @Inject constructor( SureAboutMessagingDialogState.Hidden ) + var currentLinkPreview: MessageLinkPreview? by mutableStateOf(null) + private set + init { conversationNavArgs.pendingTextBundle?.let { text -> trySendPendingMessageBundle(text) @@ -538,6 +543,17 @@ class SendMessageViewModel @Inject constructor( sureAboutMessagingDialogState = SureAboutMessagingDialogState.Hidden } + fun updateLinkPreview(text: String, mentions: List) { + viewModelScope.launch(dispatchers.io()) { + val result = generateLinkPreview(text = text, mentions = mentions) + result.onSuccess { preview -> + currentLinkPreview = preview + }.onFailure { + currentLinkPreview = null + } + } + } + private fun AssetBundle.uploadParams( assetHeight: Int? = null, assetWidth: Int? = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt new file mode 100644 index 00000000000..d978d5945db --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -0,0 +1,69 @@ +/* + * 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.home.messagecomposer + +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview + +@SuppressLint("ComposeModifierMissing") +@Composable +fun LinkPreviewCard(preview: MessageLinkPreview) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (!preview.title.isNullOrBlank()) { + Text( + text = preview.title!!, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (!preview.summary.isNullOrBlank()) { + Text( + text = preview.summary!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index d5326afae15..4530d5a3af7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -52,6 +52,7 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -159,6 +160,9 @@ internal class SendMessageViewModelArrangement { @MockK lateinit var sharedState: MessageSharedState + @MockK + lateinit var generateLinkPreview: GenerateLinkPreviewUseCase + private val viewModel by lazy { SendMessageViewModel( sendTextMessage = sendTextMessage, @@ -183,7 +187,8 @@ internal class SendMessageViewModelArrangement { analyticsManager = analyticsManager, sendMultipartMessage = sendMultipartMessage, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, - sharedState = sharedState + sharedState = sharedState, + generateLinkPreview = generateLinkPreview ) } From 8c1312e29333af63bcb84c954254aa699c18bcd2 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 1 Jun 2026 17:53:41 +0200 Subject: [PATCH 03/18] feat: base for link previews, mapping to ui --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 36e681d6f6b..38f4dd9f8f5 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 36e681d6f6bc4130d84d5be2b56002a9b5950d47 +Subproject commit 38f4dd9f8f548093265e0d59cbcbe9b17dbd8f31 From fd2bda2e7654dcfa3e4d8749c6c9c1093f1aa5a5 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 1 Jun 2026 17:58:53 +0200 Subject: [PATCH 04/18] feat: base for link previews, mapping to ui --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 38f4dd9f8f5..0804f95b077 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 38f4dd9f8f548093265e0d59cbcbe9b17dbd8f31 +Subproject commit 0804f95b077d18fb2c1ae19d0b0cf1d205d655f3 From 9e029a6eada3041ec8c23b962f92f3caaa269172 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 1 Jun 2026 18:10:22 +0200 Subject: [PATCH 05/18] feat: base for link previews, mapping to ui --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 0804f95b077..61f6c4fc673 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 0804f95b077d18fb2c1ae19d0b0cf1d205d655f3 +Subproject commit 61f6c4fc6739270601e268e191ac54c4f77af05e From 78fad6d9c55c7b8d6086cd11105db73760734fe6 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 3 Jun 2026 17:09:11 +0200 Subject: [PATCH 06/18] wip link previews --- .../messages/item/MessageContentAndStatus.kt | 10 +++- .../preview/PreviewMesssageBubbleTypes.kt | 24 +++++++++ .../home/conversations/model/MessageTypes.kt | 6 --- .../home/messagecomposer/LinkPreviewCard.kt | 50 ++++++++++++++++--- kalium | 2 +- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 3fe182b6d80..71d1fa1fc7c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -25,12 +25,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalInspectionMode -import com.wire.android.di.wireViewModel import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.di.wireViewModel import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.model.Clickable import com.wire.android.ui.common.applyIf @@ -58,6 +58,7 @@ import com.wire.android.ui.home.conversations.model.messagetypes.audio.AudioMess import com.wire.android.ui.home.conversations.model.messagetypes.location.LocationMessageContent import com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsView import com.wire.android.ui.home.conversations.model.messagetypes.video.VideoMessage +import com.wire.android.ui.home.messagecomposer.LinkPreviewCard import com.wire.android.ui.theme.Accent import com.wire.android.util.launchGeoIntent import com.wire.kalium.logic.data.asset.AssetTransferStatus @@ -239,6 +240,11 @@ private fun MessageContent( } VerticalSpace.x4() } + message.linkPreviews.takeIf { it.isNotEmpty() }?.let { + val linkPreview = it.first() + LinkPreviewCard(preview = linkPreview) + VerticalSpace.x4() + } MessageBody( messageBody = messageContent.messageBody, searchQuery = searchQuery, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMesssageBubbleTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMesssageBubbleTypes.kt index 7868bc98fb5..2fbd21aa59b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMesssageBubbleTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMesssageBubbleTypes.kt @@ -49,6 +49,7 @@ 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.message.Message +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import kotlinx.datetime.Instant import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -459,3 +460,26 @@ fun PreviewBubbleSelfImageFailedDownload() { } } } + +@PreviewMultipleThemes +@Composable +fun PreviewMessageWithLinkPreviews() = WireTheme { + Box(modifier = Modifier.background(color = colorsScheme().surface)) { + RegularMessageItem( + message = mockMessageWithText.copy( + linkPreviews = listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + permanentUrl = "https://wire.com", + title = "Wire", + summary = "Secure collaboration for teams with messaging, file sharing, and calling." + ) + ) + ), + conversationDetailsData = ConversationDetailsData.None(null), + clickActions = MessageClickActions.Content(), + isBubbleUiEnabled = true + ) + } +} 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 d837f56c86c..0f0e445f705 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 @@ -43,7 +43,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.DpSize -import com.wire.android.appLogger import com.wire.android.di.wireViewModelScoped import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset @@ -155,11 +154,6 @@ internal fun MessageBody( ) } - appLogger.d("ym. linkpreview is : $linkPreviews") - linkPreviews.firstOrNull()?.let { preview -> - VerticalSpace.x4() - com.wire.android.ui.home.messagecomposer.LinkPreviewCard(preview = preview) - } buttonList?.also { VerticalSpace.x4() MessageButtonsContent( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt index d978d5945db..7c773a83c23 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.messagecomposer import android.annotation.SuppressLint import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,22 +30,42 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.Accent +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview @SuppressLint("ComposeModifierMissing") @Composable fun LinkPreviewCard(preview: MessageLinkPreview) { Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(dimensions().spacing8x), color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + border = BorderStroke(dimensions().spacing1x, MaterialTheme.colorScheme.outline) ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(dimensions().spacing12x)) { + preview.image?.assetDataPath?.let { assetDataPath -> + val aspectRatio = preview.image + ?.takeIf { it.assetWidth > 0 && it.assetHeight > 0 } + ?.let { it.assetWidth.toFloat() / it.assetHeight.toFloat() } + ?: (16f / 9f) + + com.wire.android.ui.common.image.WireImage( + model = assetDataPath.toFile(), + contentDescription = "null", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .clip(RoundedCornerShape(dimensions().spacing4x)) + .padding(bottom = dimensions().spacing8x), + contentScale = ContentScale.Crop + ) + } if (!preview.title.isNullOrBlank()) { Text( text = preview.title!!, @@ -61,9 +82,22 @@ fun LinkPreviewCard(preview: MessageLinkPreview) { color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = dimensions().spacing4x) ) } } } } + +@PreviewMultipleThemes +@Composable +fun LinkPreviewCardPreview() = WireTheme(accent = Accent.Petrol) { + LinkPreviewCard( + MessageLinkPreview( + title = "Example Link Preview Title", + summary = "This is a summary of the link preview. It should be concise and informative.", + url = "https://www.example.com", + urlOffset = 0, + ) + ) +} diff --git a/kalium b/kalium index 61f6c4fc673..6d0c2a58228 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 61f6c4fc6739270601e268e191ac54c4f77af05e +Subproject commit 6d0c2a5822880c0497dfae0aceabcb5afcc2ec87 From fcf7f60ba515df32767b95b09cda512bee9afa26 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 8 Jun 2026 13:59:07 +0200 Subject: [PATCH 07/18] merge rebase --- .../conversations/sendmessage/SendMessageViewModel.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index ce27804d2d3..a5d69eb2b57 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -276,7 +276,7 @@ class SendMessageViewModel( text = message, mentions = mentions.map { it.intoMessageMention() } ) - val linkPreviews = linkPreviewResult.getOrNull()?.let { listOf(it) } ?: emptyList() + val linkPreviews = linkPreviewResult?.let { listOf(it) } ?: emptyList() sendTextMessage( conversationId = conversationId, @@ -542,12 +542,7 @@ class SendMessageViewModel( fun updateLinkPreview(text: String, mentions: List) { viewModelScope.launch(dispatchers.io()) { - val result = generateLinkPreview(text = text, mentions = mentions) - result.onSuccess { preview -> - currentLinkPreview = preview - }.onFailure { - currentLinkPreview = null - } + currentLinkPreview = generateLinkPreview(text = text, mentions = mentions) } } From 299a953b800f87d089954976690076b1fa2755ef Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 8 Jun 2026 18:30:28 +0200 Subject: [PATCH 08/18] feat: implement feature flag aware gate for link previews --- .../kotlin/com/wire/android/di/KaliumConfigsModule.kt | 3 ++- buildSrc/src/main/kotlin/customization/FeatureConfigs.kt | 4 +++- default.json | 9 ++++++--- kalium | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt index 7e0788c1ed1..f4c00c867a9 100644 --- a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt @@ -59,7 +59,8 @@ class KaliumConfigsModule { collaboraIntegration = BuildConfig.COLLABORA_INTEGRATION_ENABLED, dbInvalidationControlEnabled = BuildConfig.DB_INVALIDATION_CONTROL_ENABLED, domainWithFaultyKeysMap = BuildConfig.DOMAIN_REMOVAL_KEYS_FOR_REPAIR, - isDebug = BuildConfig.DEBUG + isDebug = BuildConfig.DEBUG, + linkPreviewEnabled = BuildConfig.LINK_PREVIEW_ENABLED ) } } diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index ce3c7a501f1..8363cbb7811 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -155,5 +155,7 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { CALL_QUALITY_MENU_ENABLED("call_quality_menu_enabled", ConfigType.BOOLEAN), - CALL_REACTIONS_ENABLED("call_reactions_enabled", ConfigType.BOOLEAN) + CALL_REACTIONS_ENABLED("call_reactions_enabled", ConfigType.BOOLEAN), + + LINK_PREVIEW_ENABLED("link_preview_enabled", ConfigType.BOOLEAN) } diff --git a/default.json b/default.json index ed933a25fe1..5b53ae453a2 100644 --- a/default.json +++ b/default.json @@ -73,7 +73,8 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "enforce_configuration_signature": true + "enforce_configuration_signature": true, + "link_preview_enabled": true }, "alpha": { "application_id": "com.wire.internal", @@ -88,7 +89,8 @@ "enforce_configuration_signature": true, "use_strict_mls_filter": false, "conversation_feeder_enabled": true, - "db_invalidation_control_enabled": false + "db_invalidation_control_enabled": false, + "link_preview_enabled": true }, "fdroid": { "application_id": "com.wire", @@ -174,5 +176,6 @@ "enforce_configuration_signature": true, "call_quality_menu_enabled": true, "call_reactions_enabled": true, - "nomad_profiles_enabled": true + "nomad_profiles_enabled": true, + "link_preview_enabled": false } diff --git a/kalium b/kalium index 8aa41964302..0de870474ba 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 8aa419643024d79db046ecff1008e7182f65ef61 +Subproject commit 0de870474ba9040507cea06398f7e0f3b110da28 From c14e338450196780a9e6948cab4bbae369892900 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Tue, 16 Jun 2026 18:24:35 +0200 Subject: [PATCH 09/18] feat: improvement of ui for previews, todo linkable and colors --- .../messages/item/MessageBubbleItem.kt | 15 ++ .../messages/item/MessageContentAndStatus.kt | 130 ++++++++++++------ .../ui/home/conversations/model/UIMessage.kt | 13 +- .../home/messagecomposer/LinkPreviewCard.kt | 116 ++++++++++------ kalium | 2 +- 5 files changed, 191 insertions(+), 85 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt index 074448d7ea4..06f0ce29861 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt @@ -170,6 +170,21 @@ fun MessageBubbleItem( Modifier.width(normalizedSize.width) } + is UIMessageContent.TextMessage -> { + val previewParams = message.linkPreviewParams + if (previewParams != null) { + val normalizedSize = previewParams.normalizedSize( + maxBounds = MaxBounds.ScreenFraction( + maxWFraction = dimensions().messageVisualMaxFractionWidth, + maxHFraction = dimensions().messageVisualMaxFractionHeight + ) + ) + Modifier.width(normalizedSize.width) + } else { + Modifier + } + } + else -> { Modifier } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 6bcc2e5695c..8025a5fbbc0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -35,8 +35,8 @@ import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.common.spacers.VerticalSpace -import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.conversationAssetPathsViewModel +import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.messages.QuotedMessage import com.wire.android.ui.home.conversations.messages.QuotedMessageStyle import com.wire.android.ui.home.conversations.messages.QuotedStyle @@ -209,50 +209,100 @@ private fun MessageContent( } is UIMessageContent.TextMessage -> { - Column { - messageContent.messageBody.quotedMessage?.let { - VerticalSpace.x4() - when (it) { - is UIQuotedMessage.UIQuotedData -> QuotedMessage( - conversationId = message.conversationId, - messageData = it, - style = QuotedMessageStyle( - quotedStyle = QuotedStyle.COMPLETE, - messageStyle = messageStyle, - selfAccent = accent, - senderAccent = it.senderAccent - ), - clickable = onReplyClick - ) + if (message.linkPreviews.isEmpty()) { + Column { + messageContent.messageBody.quotedMessage?.let { + VerticalSpace.x4() + when (it) { + is UIQuotedMessage.UIQuotedData -> QuotedMessage( + conversationId = message.conversationId, + messageData = it, + style = QuotedMessageStyle( + quotedStyle = QuotedStyle.COMPLETE, + messageStyle = messageStyle, + selfAccent = accent, + senderAccent = it.senderAccent + ), + clickable = onReplyClick + ) - UIQuotedMessage.UnavailableData -> QuotedUnavailable( - style = QuotedMessageStyle( - quotedStyle = QuotedStyle.COMPLETE, - messageStyle = messageStyle, - selfAccent = accent, - senderAccent = Accent.Unknown + UIQuotedMessage.UnavailableData -> QuotedUnavailable( + style = QuotedMessageStyle( + quotedStyle = QuotedStyle.COMPLETE, + messageStyle = messageStyle, + selfAccent = accent, + senderAccent = Accent.Unknown + ) ) - ) + } + VerticalSpace.x4() } - VerticalSpace.x4() + MessageBody( + messageBody = messageContent.messageBody, + searchQuery = searchQuery, + isAvailable = !message.isPending && message.isAvailable, + onOpenProfile = onOpenProfile, + buttonList = null, + messageId = message.header.messageId, + onLinkClick = onLinkClick, + messageStyle = messageStyle, + accent = accent, + linkPreviews = message.linkPreviews + ) } - message.linkPreviews.takeIf { it.isNotEmpty() }?.let { - val linkPreview = it.first() - LinkPreviewCard(preview = linkPreview) - VerticalSpace.x4() + } else { + Column { + message.linkPreviews.takeIf { it.isNotEmpty() }?.let { previews -> + LinkPreviewCard(preview = previews.first()) + } + + Column( + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing8x + ) + ) { + messageContent.messageBody.quotedMessage?.let { + when (it) { + is UIQuotedMessage.UIQuotedData -> QuotedMessage( + conversationId = message.conversationId, + messageData = it, + style = QuotedMessageStyle( + quotedStyle = QuotedStyle.COMPLETE, + messageStyle = messageStyle, + selfAccent = accent, + senderAccent = it.senderAccent + ), + clickable = onReplyClick + ) + + UIQuotedMessage.UnavailableData -> QuotedUnavailable( + style = QuotedMessageStyle( + quotedStyle = QuotedStyle.COMPLETE, + messageStyle = messageStyle, + selfAccent = accent, + senderAccent = Accent.Unknown + ) + ) + } + VerticalSpace.x4() + } + + MessageBody( + messageBody = messageContent.messageBody, + searchQuery = searchQuery, + isAvailable = !message.isPending && message.isAvailable, + onOpenProfile = onOpenProfile, + buttonList = null, + messageId = message.header.messageId, + onLinkClick = onLinkClick, + messageStyle = messageStyle, + accent = accent, + linkPreviews = message.linkPreviews + ) + } } - MessageBody( - messageBody = messageContent.messageBody, - searchQuery = searchQuery, - isAvailable = !message.isPending && message.isAvailable, - onOpenProfile = onOpenProfile, - buttonList = null, - messageId = message.header.messageId, - onLinkClick = onLinkClick, - messageStyle = messageStyle, - accent = accent, - linkPreviews = message.linkPreviews - ) } } 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 1f5fc2443b6..729ec70b53a 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 @@ -96,7 +96,18 @@ sealed interface UIMessage { else -> null } - val hasAssetParams: Boolean = assetParams != null && !decryptionFailed + val linkPreviewParams: VisualMediaParams? = + if (messageContent is UIMessageContent.TextMessage) { + linkPreviews.firstNotNullOfOrNull { preview -> + preview.image + ?.takeIf { it.assetWidth > 0 && it.assetHeight > 0 } + ?.let { VisualMediaParams(it.assetWidth, it.assetHeight) } + } + } else { + null + } + + val hasAssetParams: Boolean = (assetParams != null || linkPreviewParams != null) && !decryptionFailed private val isReplyableContent: Boolean get() = messageContent is UIMessageContent.TextMessage || diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt index 7c773a83c23..de6f0669a63 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -19,76 +19,106 @@ package com.wire.android.ui.home.messagecomposer import android.annotation.SuppressLint -import androidx.compose.foundation.BorderStroke +import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.image.WireImage import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview @SuppressLint("ComposeModifierMissing") @Composable fun LinkPreviewCard(preview: MessageLinkPreview) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(dimensions().spacing8x), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(dimensions().spacing1x, MaterialTheme.colorScheme.outline) + Column( + modifier = Modifier + .fillMaxWidth() ) { - Column(modifier = Modifier.padding(dimensions().spacing12x)) { - preview.image?.assetDataPath?.let { assetDataPath -> - val aspectRatio = preview.image - ?.takeIf { it.assetWidth > 0 && it.assetHeight > 0 } - ?.let { it.assetWidth.toFloat() / it.assetHeight.toFloat() } - ?: (16f / 9f) - - com.wire.android.ui.common.image.WireImage( - model = assetDataPath.toFile(), - contentDescription = "null", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(aspectRatio) - .clip(RoundedCornerShape(dimensions().spacing4x)) - .padding(bottom = dimensions().spacing8x), - contentScale = ContentScale.Crop + preview.displayHost?.let { host -> + Text( + text = host, + style = MaterialTheme.wireTypography.body02, + color = MaterialTheme.wireColorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing10x, + bottom = dimensions().spacing8x ) - } - if (!preview.title.isNullOrBlank()) { - Text( - text = preview.title!!, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis + ) + } + + preview.image?.assetDataPath?.let { assetDataPath -> + val aspectRatio = preview.image + ?.takeIf { it.assetWidth > 0 && it.assetHeight > 0 } + ?.let { it.assetWidth.toFloat() / it.assetHeight.toFloat() } + ?: (16f / 9f) + + WireImage( + model = assetDataPath.toFile(), + contentDescription = "null", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio), + contentScale = ContentScale.Crop + ) + } + + if (!preview.title.isNullOrBlank()) { + Text( + text = preview.title!!, + style = MaterialTheme.wireTypography.title02, + color = MaterialTheme.wireColorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing10x ) - } - if (!preview.summary.isNullOrBlank()) { - Text( - text = preview.summary!!, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = dimensions().spacing4x) + ) + } + if (!preview.summary.isNullOrBlank()) { + Text( + text = preview.summary!!, + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.secondaryText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing4x, + bottom = dimensions().spacing10x ) - } + ) } } } +private val MessageLinkPreview.displayHost: String? + get() { + val link = permanentUrl ?: url + val host = runCatching { Uri.parse(link).host }.getOrNull() + return host + ?.removePrefix("www.") + ?.takeIf { it.isNotBlank() } + } + @PreviewMultipleThemes @Composable fun LinkPreviewCardPreview() = WireTheme(accent = Accent.Petrol) { diff --git a/kalium b/kalium index 0de870474ba..6a4035329bb 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 0de870474ba9040507cea06398f7e0f3b110da28 +Subproject commit 6a4035329bbbbbbe3cdaba37afb5b0970ca872cc From 7ed6b9534df3c73b425c8f5956f82e5bb9c22351 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 1 Jul 2026 10:52:46 +0200 Subject: [PATCH 10/18] revertable, ui improvs --- .../messages/item/MessageContentAndStatus.kt | 7 +- .../home/conversations/model/MessageTypes.kt | 15 +- .../home/messagecomposer/LinkPreviewCard.kt | 67 ++++++--- .../conversations/model/MessageTypesTest.kt | 136 ++++++++++++++++++ .../kotlin/com/wire/android/util/ui/UIText.kt | 50 +++++++ 5 files changed, 252 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 77469511eec..d93da4051f1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -259,7 +259,12 @@ private fun MessageContent( } else { Column { message.linkPreviews.takeIf { it.isNotEmpty() }?.let { previews -> - LinkPreviewCard(preview = previews.first()) + LinkPreviewCard( + preview = previews.first(), + isAvailable = !message.isPending && message.isAvailable, + messageStyle = messageStyle, + onClick = { onLinkClick(previews.first().url) } + ) } Column( 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 33f410c8556..0c226daaa9a 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 @@ -82,6 +82,7 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.withoutPreviewedLink import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.AssetTransferStatus.DOWNLOAD_IN_PROGRESS import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_DOWNLOAD @@ -110,7 +111,8 @@ internal fun MessageBody( ) { val resources = LocalContext.current.resources val configuration = LocalConfiguration.current - val message = messageBody?.message + val displayMessageBody = remember(messageBody, linkPreviews) { messageBody?.withoutPreviewedLink(linkPreviews) } + val message = displayMessageBody?.message val (displayMentions, text) = remember(message, configuration) { message?.let { mapToDisplayMentions(it, resources) @@ -149,7 +151,7 @@ internal fun MessageBody( accent = accent ) - val markdownDocument = messageBody?.markdownDocument ?: remember(text) { text?.toMarkdownDocument() } + val markdownDocument = displayMessageBody?.markdownDocument ?: remember(text) { text?.toMarkdownDocument() } markdownDocument?.also { MarkdownDocument( @@ -468,3 +470,12 @@ fun mapToDisplayMentions(uiText: UIText, resources: Resources): Pair): MessageBody { + val updatedMessage = message.withoutPreviewedLink(linkPreviews) + return if (updatedMessage == message) { + this + } else { + copy(message = updatedMessage, markdownDocument = null) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt index de6f0669a63..2c4e75f5a04 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.messagecomposer import android.annotation.SuppressLint import android.net.Uri +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -30,8 +31,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.image.WireImage +import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -41,27 +44,35 @@ import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview @SuppressLint("ComposeModifierMissing") @Composable -fun LinkPreviewCard(preview: MessageLinkPreview) { +fun LinkPreviewCard( + preview: MessageLinkPreview, + isAvailable: Boolean = true, + messageStyle: MessageStyle = MessageStyle.NORMAL, + onClick: (() -> Unit)? = null, +) { + + val color = when (messageStyle) { + MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary + MessageStyle.BUBBLE_OTHER -> when { + isAvailable -> colorsScheme().otherBubble.onPrimary + else -> MaterialTheme.wireColorScheme.secondaryText + } + + MessageStyle.NORMAL -> { + when { + isAvailable -> MaterialTheme.colorScheme.onBackground + else -> MaterialTheme.wireColorScheme.secondaryText + } + } + } Column( modifier = Modifier .fillMaxWidth() - ) { - preview.displayHost?.let { host -> - Text( - text = host, - style = MaterialTheme.wireTypography.body02, - color = MaterialTheme.wireColorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding( - start = dimensions().spacing10x, - end = dimensions().spacing10x, - top = dimensions().spacing10x, - bottom = dimensions().spacing8x - ) + .combinedClickable( + enabled = onClick != null, + onClick = { onClick?.invoke() } ) - } - + ) { preview.image?.assetDataPath?.let { assetDataPath -> val aspectRatio = preview.image ?.takeIf { it.assetWidth > 0 && it.assetHeight > 0 } @@ -82,7 +93,7 @@ fun LinkPreviewCard(preview: MessageLinkPreview) { Text( text = preview.title!!, style = MaterialTheme.wireTypography.title02, - color = MaterialTheme.wireColorScheme.onSurface, + color = color, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding( @@ -95,8 +106,8 @@ fun LinkPreviewCard(preview: MessageLinkPreview) { if (!preview.summary.isNullOrBlank()) { Text( text = preview.summary!!, - style = MaterialTheme.wireTypography.body01, - color = MaterialTheme.wireColorScheme.secondaryText, + style = MaterialTheme.wireTypography.body05, + color = color, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding( @@ -107,6 +118,22 @@ fun LinkPreviewCard(preview: MessageLinkPreview) { ) ) } + preview.displayHost?.let { host -> + Text( + text = host, + style = MaterialTheme.wireTypography.body02, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing10x, + bottom = dimensions().spacing8x + ) + ) + } + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt index 9026de766d4..5cbb0f27cdd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt @@ -21,11 +21,14 @@ import android.content.res.Resources import com.wire.android.framework.TestUser import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.withoutPreviewedLink +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -93,4 +96,137 @@ class MessageTypesTest { val result = mapToDisplayMentions(uiText, mockResources) assertEquals(1, result.first.size) } + + @Test + fun `Given text containing previewed url when deriving display text then the raw link is removed`() { + val uiText = UIText.DynamicString("hello https://wire.com world") + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 6 + ) + ) + ) + + assertEquals(UIText.DynamicString("hello world"), result) + } + + @Test + fun `Given text with mentions after previewed url when deriving display text then mention offsets are shifted`() { + val mention = MessageMention( + start = 19, + length = 5, + userId = TestUser.USER_ID, + isSelfMention = false + ) + val uiText = UIText.DynamicString( + "https://wire.com @john", + listOf(mention) + ) + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0 + ) + ) + ) as UIText.DynamicString + + assertEquals("@john", result.value) + assertEquals(0, result.mentions.single().start) + } + + @Test + fun `Given text with mention before previewed url when deriving display text then earlier mention stays unchanged`() { + val mention = MessageMention( + start = 0, + length = 5, + userId = TestUser.USER_ID, + isSelfMention = false + ) + val uiText = UIText.DynamicString( + "@john https://wire.com", + listOf(mention) + ) + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 6 + ) + ) + ) as UIText.DynamicString + + assertEquals("@john", result.value) + assertEquals(0, result.mentions.single().start) + assertEquals(5, result.mentions.single().length) + } + + @Test + fun `Given markdown around previewed url when deriving display text then markdown is preserved`() { + val uiText = UIText.DynamicString("**hello** https://wire.com _world_") + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 10 + ) + ) + ) + + assertEquals(UIText.DynamicString("**hello** _world_"), result) + } + + @Test + fun `Given mismatched preview url offset when deriving display text then original text is returned unchanged`() { + val uiText = UIText.DynamicString("hello https://wire.com world") + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 7 + ) + ) + ) + + assertSame(uiText, result) + } + + @Test + fun `Given out of bounds preview url offset when deriving display text then original text is returned unchanged`() { + val uiText = UIText.DynamicString("hello https://wire.com world") + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 200 + ) + ) + ) + + assertSame(uiText, result) + } + + @Test + fun `Given non dynamic text when deriving display text then original text is returned unchanged`() { + val uiText: UIText = UIText.StringResource(android.R.string.copy) + + val result = uiText.withoutPreviewedLink( + listOf( + MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0 + ) + ) + ) + + assertSame(uiText, result) + } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt index 110dc788475..5a33ff12bde 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.wire.android.util.AnyPrimitiveAsStringSerializer +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention import kotlinx.serialization.Serializable @@ -114,6 +115,55 @@ sealed class UIText { fun String.toUIText() = UIText.DynamicString(this) +fun UIText.withoutPreviewedLink(linkPreviews: List): UIText { + val dynamicText = this as? UIText.DynamicString ?: return this + val preview = linkPreviews.firstOrNull() ?: return this + val linkStart = preview.urlOffset + val linkEnd = linkStart + preview.url.length + if (linkStart < 0 || linkEnd > dynamicText.value.length) return this + if (dynamicText.value.substring(linkStart, linkEnd) != preview.url) return this + + val whitespaceBeforeStart = generateSequence(linkStart - 1) { index -> index - 1 } + .takeWhile { index -> index >= 0 && dynamicText.value[index].isWhitespace() } + .lastOrNull() + ?.plus(0) + ?: linkStart + val whitespaceAfterEnd = generateSequence(linkEnd) { index -> index + 1 } + .takeWhile { index -> index < dynamicText.value.length && dynamicText.value[index].isWhitespace() } + .lastOrNull() + ?.plus(1) + ?: linkEnd + + val hasContentBefore = dynamicText.value.substring(0, whitespaceBeforeStart).isNotEmpty() + val hasContentAfter = dynamicText.value.substring(whitespaceAfterEnd).isNotEmpty() + + val removeStart = if (whitespaceBeforeStart < linkStart) whitespaceBeforeStart else linkStart + val removeEnd = if (whitespaceAfterEnd > linkEnd) whitespaceAfterEnd else linkEnd + val prefix = dynamicText.value.substring(0, removeStart) + val suffix = dynamicText.value.substring(removeEnd) + val replacement = if (hasContentBefore && hasContentAfter) " " else "" + val removedLength = (removeEnd - removeStart) - replacement.length + val updatedValue = buildString(dynamicText.value.length - removedLength) { + append(prefix) + append(replacement) + append(suffix) + } + + val adjustedMentions = dynamicText.mentions.mapNotNull { mention -> + val mentionEnd = mention.start + mention.length + when { + mentionEnd <= removeStart -> mention + mention.start >= removeEnd -> mention.copy(start = mention.start - removedLength) + else -> null + } + } + + return UIText.DynamicString( + value = updatedValue, + mentions = adjustedMentions + ) +} + @Suppress("SpreadOperator") fun UIText.resolveForTest(fakeStrings: Map): String = when (this) { is UIText.DynamicString -> this.value From 7a91216d0f27a4fe91ddbe346e5b79e8c664dd19 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Mon, 6 Jul 2026 12:10:34 +0200 Subject: [PATCH 11/18] feat: user config prop --- .../android/di/accountScoped/UserModule.kt | 10 +++++++ .../settings/privacy/PrivacySettingsScreen.kt | 13 ++++++++++ .../settings/privacy/PrivacySettingsState.kt | 1 + .../privacy/PrivacySettingsViewModel.kt | 26 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ .../privacy/PrivacySettingsViewModelTest.kt | 9 +++++++ kalium | 2 +- 7 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt index e1c6cac7b41..b41b9bf329b 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt @@ -58,6 +58,8 @@ import com.wire.kalium.logic.feature.user.UploadUserAvatarUseCase import com.wire.kalium.logic.feature.user.UserScope import com.wire.kalium.logic.feature.user.readReceipts.ObserveReadReceiptsEnabledUseCase import com.wire.kalium.logic.feature.user.readReceipts.PersistReadReceiptsStatusConfigUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.ObserveLinkPreviewsEnabledUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.PersistLinkPreviewsStatusConfigUseCase import com.wire.kalium.logic.feature.user.typingIndicator.ObserveTypingIndicatorEnabledUseCase import com.wire.kalium.logic.feature.user.typingIndicator.PersistTypingIndicatorStatusConfigUseCase import com.wire.kalium.logic.sync.ForegroundActionsUseCase @@ -117,6 +119,14 @@ class UserModule { fun providePersistReadReceiptsStatusConfig(userScope: UserScope): PersistReadReceiptsStatusConfigUseCase = userScope.persistReadReceiptsStatusConfig + @Provides + fun provideObserveLinkPreviewsEnabled(userScope: UserScope): ObserveLinkPreviewsEnabledUseCase = + userScope.observeLinkPreviewsEnabled + + @Provides + fun providePersistLinkPreviewsStatusConfig(userScope: UserScope): PersistLinkPreviewsStatusConfigUseCase = + userScope.persistLinkPreviewsStatusConfig + @Provides fun provideFinalizeMLSClientAfterE2EIEnrollmentUseCase(userScope: UserScope): FinalizeMLSClientAfterE2EIEnrollmentUseCase = userScope.finalizeMLSClientAfterE2EIEnrollment diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt index 28d81761657..821e7f2d900 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt @@ -51,6 +51,8 @@ fun PrivacySettingsConfigScreen( shouldShowAnalyticsUsage = state.shouldShowAnalyticsUsage, areReadReceiptsEnabled = state.areReadReceiptsEnabled, setReadReceiptsState = ::setReadReceiptsState, + areLinkPreviewsEnabled = state.areLinkPreviewsEnabled, + setLinkPreviewsState = ::setLinkPreviewsState, isTypingIndicatorEnabled = state.isTypingIndicatorEnabled, setTypingIndicatorState = ::setTypingIndicatorState, screenshotCensoringConfig = state.screenshotCensoringConfig, @@ -67,6 +69,8 @@ fun PrivacySettingsScreenContent( shouldShowAnalyticsUsage: Boolean, areReadReceiptsEnabled: Boolean, setReadReceiptsState: (Boolean) -> Unit, + areLinkPreviewsEnabled: Boolean, + setLinkPreviewsState: (Boolean) -> Unit, isTypingIndicatorEnabled: Boolean, setTypingIndicatorState: (Boolean) -> Unit, screenshotCensoringConfig: ScreenshotCensoringConfig, @@ -106,6 +110,13 @@ fun PrivacySettingsScreenContent( subtitle = stringResource(id = R.string.settings_send_read_receipts_description) ) WireDivider(color = colorsScheme().divider) + GroupConversationOptionsItem( + title = stringResource(R.string.settings_link_previews), + switchState = SwitchState.Enabled(value = areLinkPreviewsEnabled, onCheckedChange = setLinkPreviewsState), + arrowType = ArrowType.NONE, + subtitle = stringResource(id = R.string.settings_link_previews_description) + ) + WireDivider(color = colorsScheme().divider) GroupConversationOptionsItem( title = stringResource(R.string.settings_censor_screenshots), switchState = when (screenshotCensoringConfig) { @@ -145,6 +156,8 @@ fun PreviewSendReadReceipts() = WireTheme { shouldShowAnalyticsUsage = true, areReadReceiptsEnabled = true, setReadReceiptsState = {}, + areLinkPreviewsEnabled = false, + setLinkPreviewsState = {}, isTypingIndicatorEnabled = true, setTypingIndicatorState = {}, screenshotCensoringConfig = ScreenshotCensoringConfig.DISABLED, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsState.kt index 57d067e8b4b..0fa83576fbb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsState.kt @@ -22,6 +22,7 @@ data class PrivacySettingsState( val isAnalyticsUsageEnabled: Boolean = true, val shouldShowAnalyticsUsage: Boolean = false, val areReadReceiptsEnabled: Boolean = true, + val areLinkPreviewsEnabled: Boolean = false, val isTypingIndicatorEnabled: Boolean = true, val screenshotCensoringConfig: ScreenshotCensoringConfig = ScreenshotCensoringConfig.ENABLED_BY_USER, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModel.kt index 3ddd86abed7..f80d6acc5e7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModel.kt @@ -29,6 +29,9 @@ import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.LinkPreviewsConfigResult +import com.wire.kalium.logic.feature.user.linkPreviews.ObserveLinkPreviewsEnabledUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.PersistLinkPreviewsStatusConfigUseCase import com.wire.kalium.logic.feature.user.readReceipts.ObserveReadReceiptsEnabledUseCase import com.wire.kalium.logic.feature.user.readReceipts.PersistReadReceiptsStatusConfigUseCase import com.wire.kalium.logic.feature.user.readReceipts.ReadReceiptStatusConfigResult @@ -48,6 +51,8 @@ class PrivacySettingsViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val persistReadReceiptsStatusConfig: PersistReadReceiptsStatusConfigUseCase, private val observeReadReceiptsEnabled: ObserveReadReceiptsEnabledUseCase, + private val persistLinkPreviewsStatusConfig: PersistLinkPreviewsStatusConfigUseCase, + private val observeLinkPreviewsEnabled: ObserveLinkPreviewsEnabledUseCase, private val persistScreenshotCensoringConfig: PersistScreenshotCensoringConfigUseCase, private val observeScreenshotCensoringConfig: ObserveScreenshotCensoringConfigUseCase, private val persistTypingIndicatorStatusConfig: PersistTypingIndicatorStatusConfigUseCase, @@ -66,14 +71,16 @@ class PrivacySettingsViewModel @Inject constructor( val shouldShowAnalyticsUsage = shouldShowAnalyticsUsage() combine( observeReadReceiptsEnabled(), + observeLinkPreviewsEnabled(), observeTypingIndicatorEnabled(), observeScreenshotCensoringConfig(), dataStore.isAnonymousUsageDataEnabled() - ) { readReceiptsEnabled, typingIndicatorEnabled, screenshotCensoringConfig, anonymousUsageDataEnabled -> + ) { readReceiptsEnabled, linkPreviewsEnabled, typingIndicatorEnabled, screenshotCensoringConfig, anonymousUsageDataEnabled -> PrivacySettingsState( isAnalyticsUsageEnabled = anonymousUsageDataEnabled, shouldShowAnalyticsUsage = shouldShowAnalyticsUsage, areReadReceiptsEnabled = readReceiptsEnabled, + areLinkPreviewsEnabled = linkPreviewsEnabled, isTypingIndicatorEnabled = typingIndicatorEnabled, screenshotCensoringConfig = when (screenshotCensoringConfig) { ObserveScreenshotCensoringConfigResult.Disabled -> @@ -128,6 +135,23 @@ class PrivacySettingsViewModel @Inject constructor( } } } + + fun setLinkPreviewsState(isEnabled: Boolean) { + viewModelScope.launch { + state = + when (withContext(dispatchers.io()) { persistLinkPreviewsStatusConfig(isEnabled) }) { + is LinkPreviewsConfigResult.Failure -> { + appLogger.e("Something went wrong while updating link previews config") + state.copy(areLinkPreviewsEnabled = !isEnabled) + } + is LinkPreviewsConfigResult.Success -> { + appLogger.d("Link previews config changed") + state.copy(areLinkPreviewsEnabled = isEnabled) + } + } + } + } + fun setScreenshotCensoringConfig(isEnabled: Boolean) { viewModelScope.launch { when (persistScreenshotCensoringConfig(isEnabled)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 702dd7f18db..b3b3634323b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1269,6 +1269,8 @@ Send read receipts If this is on, others can see if you read their messages. In group conversations, the group admin can overwrite this setting. + Link previews + When this is on, you create link previews when you share a URL in a conversation. You also get previews sent by others if they have turned it on. Censor screenshots If this is ON, then the content of the messages will not be visible on the screenshot or screen recording. This is enforced by the self-deleting message team setting and cannot be changed.\nThe content of the messages will not be visible on the screenshot or screen recording. diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModelTest.kt index 0b2c3b66387..c951ee954fc 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsViewModelTest.kt @@ -26,6 +26,9 @@ import com.wire.android.framework.TestUser import com.wire.android.util.newServerConfig import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.ObserveLinkPreviewsEnabledUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.PersistLinkPreviewsStatusConfigUseCase +import com.wire.kalium.logic.feature.user.linkPreviews.LinkPreviewsConfigResult import com.wire.kalium.logic.feature.user.readReceipts.ObserveReadReceiptsEnabledUseCase import com.wire.kalium.logic.feature.user.readReceipts.PersistReadReceiptsStatusConfigUseCase import com.wire.kalium.logic.feature.user.readReceipts.ReadReceiptStatusConfigResult @@ -116,6 +119,8 @@ class PrivacySettingsViewModelTest { private class Arrangement { val persistReadReceiptsStatusConfig = mockk() val observeReadReceiptsEnabled = mockk() + val persistLinkPreviewsStatusConfig = mockk() + val observeLinkPreviewsEnabled = mockk() val persistScreenshotCensoringConfig = mockk() val observeScreenshotCensoringConfig = mockk() val persistTypingIndicatorStatusConfig = mockk() @@ -129,6 +134,8 @@ class PrivacySettingsViewModelTest { dispatchers = TestDispatcherProvider(), persistReadReceiptsStatusConfig = persistReadReceiptsStatusConfig, observeReadReceiptsEnabled = observeReadReceiptsEnabled, + persistLinkPreviewsStatusConfig = persistLinkPreviewsStatusConfig, + observeLinkPreviewsEnabled = observeLinkPreviewsEnabled, persistScreenshotCensoringConfig = persistScreenshotCensoringConfig, observeScreenshotCensoringConfig = observeScreenshotCensoringConfig, persistTypingIndicatorStatusConfig = persistTypingIndicatorStatusConfig, @@ -145,6 +152,8 @@ class PrivacySettingsViewModelTest { coEvery { persistReadReceiptsStatusConfig.invoke(true) } returns ReadReceiptStatusConfigResult.Success coEvery { observeReadReceiptsEnabled() } returns flowOf(true) + coEvery { persistLinkPreviewsStatusConfig.invoke(true) } returns LinkPreviewsConfigResult.Success + coEvery { observeLinkPreviewsEnabled() } returns flowOf(false) coEvery { persistScreenshotCensoringConfig.invoke(true) } returns PersistScreenshotCensoringConfigResult.Success coEvery { observeScreenshotCensoringConfig() } returns flowOf(ObserveScreenshotCensoringConfigResult.Enabled.ChosenByUser) diff --git a/kalium b/kalium index c951e3215c9..b6e0aac9f1f 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit c951e3215c91452adfdf58fb4237b237e36ba8c6 +Subproject commit b6e0aac9f1f028712b8b50fcf176a4cad468150c From db50a45a96511e5634c5190b34a9041d930b7e03 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Tue, 7 Jul 2026 17:57:17 +0200 Subject: [PATCH 12/18] feat: simplify generation --- .../home/conversations/model/MessageTypes.kt | 15 +- .../conversations/model/MessageTypesTest.kt | 136 ------------------ .../kotlin/com/wire/android/util/ui/UIText.kt | 50 ------- 3 files changed, 2 insertions(+), 199 deletions(-) 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 0c226daaa9a..33f410c8556 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 @@ -82,7 +82,6 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.UIText -import com.wire.android.util.ui.withoutPreviewedLink import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.AssetTransferStatus.DOWNLOAD_IN_PROGRESS import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_DOWNLOAD @@ -111,8 +110,7 @@ internal fun MessageBody( ) { val resources = LocalContext.current.resources val configuration = LocalConfiguration.current - val displayMessageBody = remember(messageBody, linkPreviews) { messageBody?.withoutPreviewedLink(linkPreviews) } - val message = displayMessageBody?.message + val message = messageBody?.message val (displayMentions, text) = remember(message, configuration) { message?.let { mapToDisplayMentions(it, resources) @@ -151,7 +149,7 @@ internal fun MessageBody( accent = accent ) - val markdownDocument = displayMessageBody?.markdownDocument ?: remember(text) { text?.toMarkdownDocument() } + val markdownDocument = messageBody?.markdownDocument ?: remember(text) { text?.toMarkdownDocument() } markdownDocument?.also { MarkdownDocument( @@ -470,12 +468,3 @@ fun mapToDisplayMentions(uiText: UIText, resources: Resources): Pair): MessageBody { - val updatedMessage = message.withoutPreviewedLink(linkPreviews) - return if (updatedMessage == message) { - this - } else { - copy(message = updatedMessage, markdownDocument = null) - } -} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt index 5cbb0f27cdd..9026de766d4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesTest.kt @@ -21,14 +21,11 @@ import android.content.res.Resources import com.wire.android.framework.TestUser import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.util.ui.UIText -import com.wire.android.util.ui.withoutPreviewedLink -import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -96,137 +93,4 @@ class MessageTypesTest { val result = mapToDisplayMentions(uiText, mockResources) assertEquals(1, result.first.size) } - - @Test - fun `Given text containing previewed url when deriving display text then the raw link is removed`() { - val uiText = UIText.DynamicString("hello https://wire.com world") - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 6 - ) - ) - ) - - assertEquals(UIText.DynamicString("hello world"), result) - } - - @Test - fun `Given text with mentions after previewed url when deriving display text then mention offsets are shifted`() { - val mention = MessageMention( - start = 19, - length = 5, - userId = TestUser.USER_ID, - isSelfMention = false - ) - val uiText = UIText.DynamicString( - "https://wire.com @john", - listOf(mention) - ) - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 0 - ) - ) - ) as UIText.DynamicString - - assertEquals("@john", result.value) - assertEquals(0, result.mentions.single().start) - } - - @Test - fun `Given text with mention before previewed url when deriving display text then earlier mention stays unchanged`() { - val mention = MessageMention( - start = 0, - length = 5, - userId = TestUser.USER_ID, - isSelfMention = false - ) - val uiText = UIText.DynamicString( - "@john https://wire.com", - listOf(mention) - ) - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 6 - ) - ) - ) as UIText.DynamicString - - assertEquals("@john", result.value) - assertEquals(0, result.mentions.single().start) - assertEquals(5, result.mentions.single().length) - } - - @Test - fun `Given markdown around previewed url when deriving display text then markdown is preserved`() { - val uiText = UIText.DynamicString("**hello** https://wire.com _world_") - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 10 - ) - ) - ) - - assertEquals(UIText.DynamicString("**hello** _world_"), result) - } - - @Test - fun `Given mismatched preview url offset when deriving display text then original text is returned unchanged`() { - val uiText = UIText.DynamicString("hello https://wire.com world") - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 7 - ) - ) - ) - - assertSame(uiText, result) - } - - @Test - fun `Given out of bounds preview url offset when deriving display text then original text is returned unchanged`() { - val uiText = UIText.DynamicString("hello https://wire.com world") - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 200 - ) - ) - ) - - assertSame(uiText, result) - } - - @Test - fun `Given non dynamic text when deriving display text then original text is returned unchanged`() { - val uiText: UIText = UIText.StringResource(android.R.string.copy) - - val result = uiText.withoutPreviewedLink( - listOf( - MessageLinkPreview( - url = "https://wire.com", - urlOffset = 0 - ) - ) - ) - - assertSame(uiText, result) - } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt index 5a33ff12bde..110dc788475 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/UIText.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.wire.android.util.AnyPrimitiveAsStringSerializer -import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention import kotlinx.serialization.Serializable @@ -115,55 +114,6 @@ sealed class UIText { fun String.toUIText() = UIText.DynamicString(this) -fun UIText.withoutPreviewedLink(linkPreviews: List): UIText { - val dynamicText = this as? UIText.DynamicString ?: return this - val preview = linkPreviews.firstOrNull() ?: return this - val linkStart = preview.urlOffset - val linkEnd = linkStart + preview.url.length - if (linkStart < 0 || linkEnd > dynamicText.value.length) return this - if (dynamicText.value.substring(linkStart, linkEnd) != preview.url) return this - - val whitespaceBeforeStart = generateSequence(linkStart - 1) { index -> index - 1 } - .takeWhile { index -> index >= 0 && dynamicText.value[index].isWhitespace() } - .lastOrNull() - ?.plus(0) - ?: linkStart - val whitespaceAfterEnd = generateSequence(linkEnd) { index -> index + 1 } - .takeWhile { index -> index < dynamicText.value.length && dynamicText.value[index].isWhitespace() } - .lastOrNull() - ?.plus(1) - ?: linkEnd - - val hasContentBefore = dynamicText.value.substring(0, whitespaceBeforeStart).isNotEmpty() - val hasContentAfter = dynamicText.value.substring(whitespaceAfterEnd).isNotEmpty() - - val removeStart = if (whitespaceBeforeStart < linkStart) whitespaceBeforeStart else linkStart - val removeEnd = if (whitespaceAfterEnd > linkEnd) whitespaceAfterEnd else linkEnd - val prefix = dynamicText.value.substring(0, removeStart) - val suffix = dynamicText.value.substring(removeEnd) - val replacement = if (hasContentBefore && hasContentAfter) " " else "" - val removedLength = (removeEnd - removeStart) - replacement.length - val updatedValue = buildString(dynamicText.value.length - removedLength) { - append(prefix) - append(replacement) - append(suffix) - } - - val adjustedMentions = dynamicText.mentions.mapNotNull { mention -> - val mentionEnd = mention.start + mention.length - when { - mentionEnd <= removeStart -> mention - mention.start >= removeEnd -> mention.copy(start = mention.start - removedLength) - else -> null - } - } - - return UIText.DynamicString( - value = updatedValue, - mentions = adjustedMentions - ) -} - @Suppress("SpreadOperator") fun UIText.resolveForTest(fakeStrings: Map): String = when (this) { is UIText.DynamicString -> this.value From 73d97666d16172f2325488c943caec187bcfc10b Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 8 Jul 2026 16:03:43 +0200 Subject: [PATCH 13/18] feat: fetch metadata early --- .../home/conversations/ConversationScreen.kt | 25 +++ .../ComposerLinkPreviewTargetDetector.kt | 69 ++++++++ .../sendmessage/SendMessageViewModel.kt | 101 ++++++++++- .../ComposerLinkPreviewTargetDetectorTest.kt | 64 +++++++ .../SendMessageViewModelArrangement.kt | 5 + .../sendmessage/SendMessageViewModelTest.kt | 164 ++++++++++++++++++ 6 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt 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 6816949e7bd..7741e2347ac 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 @@ -132,6 +132,7 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.snackbar.SwipeableSnackbar import com.wire.android.ui.common.spacers.HorizontalSpace +import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.emoji.EmojiPickerBottomSheet import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldHaveSmallBottomPadding @@ -205,6 +206,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.datetime.Instant @@ -337,6 +340,28 @@ fun ConversationScreen( } } + LaunchedEffect( + messageComposerStateHolder.messageCompositionInputStateHolder.messageTextState, + messageAttachmentsViewModel.attachments, + ) { + if (messageAttachmentsViewModel.attachments.isNotEmpty()) { + sendMessageViewModel.clearLinkPreview() + return@LaunchedEffect + } + + messageComposerStateHolder.messageCompositionInputStateHolder.messageTextState + .textAsFlow() + .distinctUntilChanged() + .collectLatest { text -> + sendMessageViewModel.updateLinkPreview( + text = text.toString(), + mentions = messageComposerStateHolder.messageComposition.value.selectedMentions.map { + it.intoMessageMention() + } + ) + } + } + conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> navigator.navigate( NavigationCommand( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt new file mode 100644 index 00000000000..44820ac7e29 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt @@ -0,0 +1,69 @@ +/* + * 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.home.conversations.sendmessage + +import com.wire.kalium.logic.data.message.mention.MessageMention + +internal data class ComposerLinkPreviewTarget( + val url: String, + val position: Int, +) + +internal object ComposerLinkPreviewTargetDetector { + private val webUrl = Regex( + "(?:(?:https?|ftp)://(?:(?:[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?\\.)+" + + "(?:com|org|net|edu|gov|mil|int|mobi|name|aero|asia|biz|cat|coop|info|jobs|museum|tel|travel|xxx|[a-z]{2})|" + + "(?:(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])\\.){3}" + + "(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])|" + + "(?:(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}))(?::[0-9]+)?" + + "(?:/[a-z0-9\\-._~:/?#\\[\\]@!\$&'()*+,;=]*)?)|" + + "(?:www\\.(?:[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?\\.)+" + + "(?:com|org|net|edu|gov|mil|int|mobi|name|aero|asia|biz|cat|coop|info|jobs|museum|tel|travel|xxx|[a-z]{2})|" + + "(?:(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])\\.){3}" + + "(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])|" + + "(?:(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}))(?::[0-9]+)?" + + "(?:/[a-z0-9\\-._~:/?#\\[\\]@!\$&'()*+,;=]*)?", + RegexOption.IGNORE_CASE + ) + + fun detect( + text: String, + mentions: List = emptyList(), + ): ComposerLinkPreviewTarget? { + val excludedRanges = buildExcludedRanges(text, mentions) + return webUrl.findAll(text) + .map { ComposerLinkPreviewTarget(it.value, it.range.first) } + .firstOrNull { candidate -> + val candidateRange = candidate.position until (candidate.position + candidate.url.length) + excludedRanges.none { excluded -> + candidateRange.first < excluded.last + 1 && excluded.first < candidateRange.last + 1 + } + } + } + + private fun buildExcludedRanges( + text: String, + mentions: List, + ): List { + val ranges = mentions.map { it.start until (it.start + it.length) }.toMutableList() + Regex("""\[.+?\]\((.+?)\)""") + .findAll(text) + .forEach { ranges.add(it.range) } + return ranges + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index a5d69eb2b57..b4d1f741255 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -79,12 +79,14 @@ import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Suppress("LongParameterList", "TooManyFunctions") class SendMessageViewModel( @@ -131,6 +133,9 @@ class SendMessageViewModel( var currentLinkPreview: MessageLinkPreview? by mutableStateOf(null) private set + private var activeLinkPreviewTarget: ComposerLinkPreviewTarget? = null + private var activeLinkPreviewRequest: Job? = null + init { conversationNavArgs.pendingTextBundle?.let { text -> trySendPendingMessageBundle(text) @@ -271,16 +276,21 @@ class SendMessageViewModel( removeMessageDraft(messageBundle.conversationId) sendTypingEvent(messageBundle.conversationId, TypingIndicatorMode.STOPPED) with(messageBundle) { - // Generate link preview if present - val linkPreviewResult = generateLinkPreview( + val linkPreviews = resolveLinkPreviewsForSend( text = message, mentions = mentions.map { it.intoMessageMention() } ) - val linkPreviews = linkPreviewResult?.let { listOf(it) } ?: emptyList() + val textToSend = message.removeStandalonePreviewUrl( + target = ComposerLinkPreviewTargetDetector.detect( + text = message, + mentions = mentions.map { it.intoMessageMention() } + ), + hasPreview = linkPreviews.isNotEmpty() + ) sendTextMessage( conversationId = conversationId, - text = message, + text = textToSend, linkPreviews = linkPreviews, mentions = mentions.map { it.intoMessageMention() }, quotedMessageId = quotedMessageId @@ -541,8 +551,87 @@ class SendMessageViewModel( } fun updateLinkPreview(text: String, mentions: List) { - viewModelScope.launch(dispatchers.io()) { - currentLinkPreview = generateLinkPreview(text = text, mentions = mentions) + val nextTarget = ComposerLinkPreviewTargetDetector.detect(text, mentions) + val currentTarget = activeLinkPreviewTarget + + if (nextTarget == null) { + clearLinkPreview() + return + } + + if (currentTarget == nextTarget) { + return + } + + if (currentTarget?.url == nextTarget.url) { + activeLinkPreviewTarget = nextTarget + currentLinkPreview = currentLinkPreview?.copy(urlOffset = nextTarget.position) + return + } + + activeLinkPreviewRequest?.cancel() + activeLinkPreviewTarget = nextTarget + currentLinkPreview = null + activeLinkPreviewRequest = viewModelScope.launch { + val preview = try { + withContext(dispatchers.io()) { + generateLinkPreview(text = text, mentions = mentions) + } + } catch (exception: CancellationException) { + throw exception + } catch (_: Throwable) { + null + } + + val latestTarget = activeLinkPreviewTarget + if (latestTarget?.url == nextTarget.url) { + currentLinkPreview = preview?.copy(urlOffset = latestTarget.position) + } + } + } + + fun clearLinkPreview() { + activeLinkPreviewRequest?.cancel() + activeLinkPreviewRequest = null + activeLinkPreviewTarget = null + currentLinkPreview = null + } + + private suspend fun resolveLinkPreviewsForSend( + text: String, + mentions: List, + ): List { + val target = ComposerLinkPreviewTargetDetector.detect(text, mentions) ?: return emptyList() + val activeTarget = activeLinkPreviewTarget + + if (activeTarget?.url == target.url) { + return currentLinkPreview + ?.copy(urlOffset = target.position) + ?.let(::listOf) + ?: emptyList() + } + + val generatedPreview = generateLinkPreview(text = text, mentions = mentions) + return generatedPreview?.let(::listOf) ?: emptyList() + } + + private fun String.removeStandalonePreviewUrl( + target: ComposerLinkPreviewTarget?, + hasPreview: Boolean, + ): String { + if (!hasPreview || target == null) return this + + val rangeStart = target.position + val rangeEnd = rangeStart + target.url.length + if (rangeStart < 0 || rangeEnd > length) return this + + val before = substring(0, rangeStart) + val after = substring(rangeEnd, length) + + return if (before.isBlank() && after.isBlank()) { + (before + after).trim() + } else { + this } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt new file mode 100644 index 00000000000..0a5b4a8222d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt @@ -0,0 +1,64 @@ +/* + * 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.home.conversations.sendmessage + +import com.wire.kalium.logic.data.message.mention.MessageMention +import com.wire.kalium.logic.data.user.UserId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ComposerLinkPreviewTargetDetectorTest { + + @Test + fun `given text with link when detecting then returns first link and position`() { + val result = ComposerLinkPreviewTargetDetector.detect("hello https://wire.com world") + + assertEquals( + ComposerLinkPreviewTarget( + url = "https://wire.com", + position = 6 + ), + result + ) + } + + @Test + fun `given markdown link when detecting then returns null`() { + val result = ComposerLinkPreviewTargetDetector.detect("[Wire](https://wire.com)") + + assertEquals(null, result) + } + + @Test + fun `given mentioned url when detecting then skips excluded mention range`() { + val text = "@wire.com hello" + val result = ComposerLinkPreviewTargetDetector.detect( + text = text, + mentions = listOf( + MessageMention( + start = 0, + length = 9, + userId = UserId("user", "wire.com"), + isSelfMention = false + ) + ) + ) + + assertEquals(null, result) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index 4530d5a3af7..fa36bb00609 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -32,6 +32,7 @@ import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.ImageUtil import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.feature.asset.upload.ScheduleNewAssetMessageResult import com.wire.kalium.logic.feature.asset.upload.ScheduleNewAssetMessageUseCase @@ -302,5 +303,9 @@ internal class SendMessageViewModelArrangement { coEvery { isWireCellsEnabledForConversation.invoke(conversationId) } returns result } + fun withGeneratedLinkPreviewResult(result: MessageLinkPreview?) = apply { + coEvery { generateLinkPreview(any(), any()) } returns result + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index 23c5ad8bf84..b9e1186c089 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -35,6 +35,7 @@ import com.wire.android.ui.home.messagecomposer.model.Ping import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.upload.AssetUploadParams import com.wire.kalium.logic.feature.asset.upload.ScheduleNewAssetMessageResult @@ -71,6 +72,169 @@ class SendMessageViewModelTest { Dispatchers.resetMain() } + @Test + fun `given same composer url when updating link preview twice then it is fetched only once`() = runTest { + val preview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withGeneratedLinkPreviewResult(preview) + .arrange() + + viewModel.updateLinkPreview("https://wire.com", emptyList()) + advanceUntilIdle() + viewModel.updateLinkPreview("hello https://wire.com", emptyList()) + advanceUntilIdle() + + assertEquals(6, viewModel.currentLinkPreview?.urlOffset) + coVerify(exactly = 1) { + arrangement.generateLinkPreview(any(), any()) + } + } + + @Test + fun `given composer url changes when new preview fetch fails then cached preview is removed`() = runTest { + val initialPreview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .arrange() + + io.mockk.coEvery { + arrangement.generateLinkPreview("https://wire.com", any()) + } returns initialPreview + io.mockk.coEvery { + arrangement.generateLinkPreview("https://example.com", any()) + } returns null + + viewModel.updateLinkPreview("https://wire.com", emptyList()) + advanceUntilIdle() + assertEquals("https://wire.com", viewModel.currentLinkPreview?.url) + + viewModel.updateLinkPreview("https://example.com", emptyList()) + advanceUntilIdle() + + assertEquals(null, viewModel.currentLinkPreview) + } + + @Test + fun `given prefetched composer preview when sending same text then cached preview is reused`() = runTest { + val preview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withGeneratedLinkPreviewResult(preview) + .withSuccessfulSendTextMessage() + .arrange() + + viewModel.updateLinkPreview("https://wire.com", emptyList()) + advanceUntilIdle() + + viewModel.trySendMessage( + ComposableMessageBundle.SendTextMessageBundle( + conversationId = conversationId, + message = "https://wire.com", + mentions = emptyList(), + ) + ) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.generateLinkPreview(any(), any()) + } + coVerify(exactly = 1) { + arrangement.sendTextMessage( + conversationId = conversationId, + text = "https://wire.com", + linkPreviews = listOf(preview), + mentions = emptyList(), + quotedMessageId = null + ) + } + } + + @Test + fun `given standalone link with preview when sending then raw link is removed from outgoing text`() = runTest { + val preview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withGeneratedLinkPreviewResult(preview) + .withSuccessfulSendTextMessage() + .arrange() + + viewModel.updateLinkPreview(" https://wire.com ", emptyList()) + advanceUntilIdle() + + viewModel.trySendMessage( + ComposableMessageBundle.SendTextMessageBundle( + conversationId = conversationId, + message = " https://wire.com ", + mentions = emptyList(), + ) + ) + advanceUntilIdle() + + coVerify { + arrangement.sendTextMessage( + conversationId = conversationId, + text = "", + linkPreviews = listOf(preview), + mentions = emptyList(), + quotedMessageId = null + ) + } + } + + @Test + fun `given link with surrounding text when sending then raw link remains in outgoing text`() = runTest { + val preview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 6, + title = "Wire" + ) + val message = "hello https://wire.com world" + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withGeneratedLinkPreviewResult(preview) + .withSuccessfulSendTextMessage() + .arrange() + + viewModel.updateLinkPreview(message, emptyList()) + advanceUntilIdle() + + viewModel.trySendMessage( + ComposableMessageBundle.SendTextMessageBundle( + conversationId = conversationId, + message = message, + mentions = emptyList(), + ) + ) + advanceUntilIdle() + + coVerify { + arrangement.sendTextMessage( + conversationId = conversationId, + text = message, + linkPreviews = listOf(preview), + mentions = emptyList(), + quotedMessageId = null + ) + } + } + @Test fun `given the user sends an asset message, when invoked, then sendAssetMessageUseCase gets called`() = runTest { From 392100d77c8eae8ce57e396252195fe97b57293c Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 8 Jul 2026 16:20:52 +0200 Subject: [PATCH 14/18] feat: fetch metadata early --- .../android/di/accountScoped/MessageModule.kt | 5 ++ .../ConversationCoreViewModelFactory.kt | 5 +- .../home/conversations/ConversationScreen.kt | 11 ++- .../ComposerLinkPreviewTargetDetector.kt | 69 ------------------- .../sendmessage/SendMessageViewModel.kt | 23 +++++-- .../messagecomposer/model/MessageBundle.kt | 4 +- .../ComposerLinkPreviewTargetDetectorTest.kt | 64 ----------------- .../SendMessageViewModelArrangement.kt | 26 ++++++- .../sendmessage/SendMessageViewModelTest.kt | 46 ++++++++++++- 9 files changed, 108 insertions(+), 145 deletions(-) delete mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt delete mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index 2150e2dba36..9025c36019c 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -60,6 +60,7 @@ import com.wire.kalium.logic.feature.message.fetchOlderMessagesByConversationId import com.wire.kalium.logic.feature.message.getPaginatedFlowOfAssetMessageByConversationId import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesByConversation import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesBySearchQueryAndConversation +import com.wire.kalium.logic.feature.message.linkpreview.DetectLinkPreviewTargetUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import com.wire.kalium.logic.feature.message.observePaginatedImageAssetMessageByConversationId import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -107,6 +108,10 @@ class MessageModule { fun provideGenerateLinkPreviewUseCase(messageScope: MessageScope): GenerateLinkPreviewUseCase = messageScope.generateLinkPreview + @Provides + fun provideDetectLinkPreviewTargetUseCase(messageScope: MessageScope): DetectLinkPreviewTargetUseCase = + messageScope.detectLinkPreviewTarget + @Provides fun provideSendEditTextMessageUseCase(messageScope: MessageScope): SendEditTextMessageUseCase = messageScope.sendEditTextMessage diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt index 658743fafd4..995e2e66822 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt @@ -112,6 +112,7 @@ import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletion import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase +import com.wire.kalium.logic.feature.message.linkpreview.DetectLinkPreviewTargetUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase import com.wire.kalium.logic.featureFlags.KaliumConfigs @@ -199,6 +200,7 @@ class ConversationCoreViewModelFactory @Inject constructor( private val observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase, private val isSelfUserViewerOnConversation: IsSelfUserViewerOnConversationUseCase, private val generateLinkPreview: GenerateLinkPreviewUseCase, + private val detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase, private val networkStateObserver: NetworkStateObserver, @CurrentAccount private val selfUserId: UserId, ) { @@ -268,7 +270,8 @@ class ConversationCoreViewModelFactory @Inject constructor( analyticsManager = analyticsManager, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, sharedState = messageSharedState, - generateLinkPreview = generateLinkPreview + generateLinkPreview = generateLinkPreview, + detectLinkPreviewTarget = detectLinkPreviewTarget ) fun messageDraftViewModel(savedStateHandle: SavedStateHandle) = MessageDraftViewModel( 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 7741e2347ac..13a16ffe409 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 @@ -445,7 +445,7 @@ fun ConversationScreen( NavigationCommand(MessageDetailsScreenDestination(conversationInfoViewModel.conversationId, messageId, isSelfMessage)) ) }, - onSendMessage = { sendMessageViewModel.trySendMessage(it) }, + onSendMessage = { sendMessageViewModel.trySendMessage(it.withPrefetchedLinkPreview(sendMessageViewModel.currentLinkPreview)) }, onPingOptionClicked = { if (conversationCallViewModel.conversationCallViewState.participantsCount > MAX_GROUP_SIZE_FOR_PING) { showDialog.value = ConversationScreenDialogType.PING_CONFIRMATION @@ -786,6 +786,15 @@ fun ConversationScreen( } } +private fun MessageBundle.withPrefetchedLinkPreview( + linkPreview: com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview? +): MessageBundle { + return when (this) { + is ComposableMessageBundle.SendTextMessageBundle -> copy(prefetchedLinkPreview = linkPreview) + else -> this + } +} + private fun conversationScreenOnBackButtonClick( messageComposerViewModel: MessageComposerViewModel, messageComposerStateHolder: MessageComposerStateHolder, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt deleted file mode 100644 index 44820ac7e29..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetector.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.home.conversations.sendmessage - -import com.wire.kalium.logic.data.message.mention.MessageMention - -internal data class ComposerLinkPreviewTarget( - val url: String, - val position: Int, -) - -internal object ComposerLinkPreviewTargetDetector { - private val webUrl = Regex( - "(?:(?:https?|ftp)://(?:(?:[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?\\.)+" + - "(?:com|org|net|edu|gov|mil|int|mobi|name|aero|asia|biz|cat|coop|info|jobs|museum|tel|travel|xxx|[a-z]{2})|" + - "(?:(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])\\.){3}" + - "(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])|" + - "(?:(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}))(?::[0-9]+)?" + - "(?:/[a-z0-9\\-._~:/?#\\[\\]@!\$&'()*+,;=]*)?)|" + - "(?:www\\.(?:[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?\\.)+" + - "(?:com|org|net|edu|gov|mil|int|mobi|name|aero|asia|biz|cat|coop|info|jobs|museum|tel|travel|xxx|[a-z]{2})|" + - "(?:(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])\\.){3}" + - "(?:25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9]?[0-9])|" + - "(?:(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}))(?::[0-9]+)?" + - "(?:/[a-z0-9\\-._~:/?#\\[\\]@!\$&'()*+,;=]*)?", - RegexOption.IGNORE_CASE - ) - - fun detect( - text: String, - mentions: List = emptyList(), - ): ComposerLinkPreviewTarget? { - val excludedRanges = buildExcludedRanges(text, mentions) - return webUrl.findAll(text) - .map { ComposerLinkPreviewTarget(it.value, it.range.first) } - .firstOrNull { candidate -> - val candidateRange = candidate.position until (candidate.position + candidate.url.length) - excludedRanges.none { excluded -> - candidateRange.first < excluded.last + 1 && excluded.first < candidateRange.last + 1 - } - } - } - - private fun buildExcludedRanges( - text: String, - mentions: List, - ): List { - val ranges = mentions.map { it.start until (it.start + it.length) }.toMutableList() - Regex("""\[.+?\]\((.+?)\)""") - .findAll(text) - .forEach { ranges.add(it.range) } - return ranges - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index b4d1f741255..31825917866 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -76,7 +76,9 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.linkpreview.DetectLinkPreviewTargetUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase +import com.wire.kalium.logic.feature.message.linkpreview.LinkPreviewTarget import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.data.message.mention.MessageMention import kotlinx.coroutines.CancellationException @@ -113,7 +115,8 @@ class SendMessageViewModel( private val analyticsManager: AnonymousAnalyticsManager, private val isWireCellsEnabledForConversation: IsWireCellsEnabledForConversationUseCase, private val sharedState: MessageSharedState, - private val generateLinkPreview: GenerateLinkPreviewUseCase + private val generateLinkPreview: GenerateLinkPreviewUseCase, + private val detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase, ) : ViewModel() { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -133,7 +136,7 @@ class SendMessageViewModel( var currentLinkPreview: MessageLinkPreview? by mutableStateOf(null) private set - private var activeLinkPreviewTarget: ComposerLinkPreviewTarget? = null + private var activeLinkPreviewTarget: LinkPreviewTarget? = null private var activeLinkPreviewRequest: Job? = null init { @@ -278,10 +281,11 @@ class SendMessageViewModel( with(messageBundle) { val linkPreviews = resolveLinkPreviewsForSend( text = message, - mentions = mentions.map { it.intoMessageMention() } + mentions = mentions.map { it.intoMessageMention() }, + prefetchedLinkPreview = prefetchedLinkPreview, ) val textToSend = message.removeStandalonePreviewUrl( - target = ComposerLinkPreviewTargetDetector.detect( + target = detectLinkPreviewTarget( text = message, mentions = mentions.map { it.intoMessageMention() } ), @@ -551,7 +555,7 @@ class SendMessageViewModel( } fun updateLinkPreview(text: String, mentions: List) { - val nextTarget = ComposerLinkPreviewTargetDetector.detect(text, mentions) + val nextTarget = detectLinkPreviewTarget(text, mentions) val currentTarget = activeLinkPreviewTarget if (nextTarget == null) { @@ -600,10 +604,15 @@ class SendMessageViewModel( private suspend fun resolveLinkPreviewsForSend( text: String, mentions: List, + prefetchedLinkPreview: MessageLinkPreview?, ): List { - val target = ComposerLinkPreviewTargetDetector.detect(text, mentions) ?: return emptyList() + val target = detectLinkPreviewTarget(text, mentions) ?: return emptyList() val activeTarget = activeLinkPreviewTarget + if (prefetchedLinkPreview?.url == target.url) { + return listOf(prefetchedLinkPreview.copy(urlOffset = target.position)) + } + if (activeTarget?.url == target.url) { return currentLinkPreview ?.copy(urlOffset = target.position) @@ -616,7 +625,7 @@ class SendMessageViewModel( } private fun String.removeStandalonePreviewUrl( - target: ComposerLinkPreviewTarget?, + target: LinkPreviewTarget?, hasPreview: Boolean, ): String { if (!hasPreview || target == null) return this diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt index eb6898e8ac4..69c22987698 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt @@ -22,6 +22,7 @@ import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview sealed class MessageBundle(open val conversationId: ConversationId) @@ -44,7 +45,8 @@ sealed class ComposableMessageBundle(override val conversationId: ConversationId override val conversationId: ConversationId, val message: String, val mentions: List, - val quotedMessageId: String? = null + val quotedMessageId: String? = null, + val prefetchedLinkPreview: MessageLinkPreview? = null, ) : ComposableMessageBundle(conversationId) data class SendMultipartMessageBundle( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt deleted file mode 100644 index 0a5b4a8222d..00000000000 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/ComposerLinkPreviewTargetDetectorTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.home.conversations.sendmessage - -import com.wire.kalium.logic.data.message.mention.MessageMention -import com.wire.kalium.logic.data.user.UserId -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ComposerLinkPreviewTargetDetectorTest { - - @Test - fun `given text with link when detecting then returns first link and position`() { - val result = ComposerLinkPreviewTargetDetector.detect("hello https://wire.com world") - - assertEquals( - ComposerLinkPreviewTarget( - url = "https://wire.com", - position = 6 - ), - result - ) - } - - @Test - fun `given markdown link when detecting then returns null`() { - val result = ComposerLinkPreviewTargetDetector.detect("[Wire](https://wire.com)") - - assertEquals(null, result) - } - - @Test - fun `given mentioned url when detecting then skips excluded mention range`() { - val text = "@wire.com hello" - val result = ComposerLinkPreviewTargetDetector.detect( - text = text, - mentions = listOf( - MessageMention( - start = 0, - length = 9, - userId = UserId("user", "wire.com"), - isSelfMention = false - ) - ) - ) - - assertEquals(null, result) - } -} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index fa36bb00609..e34afb5018f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -53,11 +53,14 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.linkpreview.DetectLinkPreviewTargetUseCase import com.wire.kalium.logic.feature.message.linkpreview.GenerateLinkPreviewUseCase +import com.wire.kalium.logic.feature.message.linkpreview.LinkPreviewTarget import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every +import io.mockk.firstArg import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -85,6 +88,19 @@ internal class SendMessageViewModelArrangement { coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(true) coEvery { setNotifiedAboutConversationUnderLegalHold(any()) } returns Unit coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(true) + every { detectLinkPreviewTarget(any(), any()) } answers { + val text = firstArg() + val start = text.indexOf("https://") + if (start < 0) { + null + } else { + val end = text.indexOf(' ', start).takeIf { it >= 0 } ?: text.length + LinkPreviewTarget( + url = text.substring(start, end), + position = start + ) + } + } } @MockK @@ -164,6 +180,9 @@ internal class SendMessageViewModelArrangement { @MockK lateinit var generateLinkPreview: GenerateLinkPreviewUseCase + @MockK + lateinit var detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase + private val viewModel by lazy { SendMessageViewModel( sendTextMessage = sendTextMessage, @@ -189,7 +208,8 @@ internal class SendMessageViewModelArrangement { sendMultipartMessage = sendMultipartMessage, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, sharedState = sharedState, - generateLinkPreview = generateLinkPreview + generateLinkPreview = generateLinkPreview, + detectLinkPreviewTarget = detectLinkPreviewTarget ) } @@ -307,5 +327,9 @@ internal class SendMessageViewModelArrangement { coEvery { generateLinkPreview(any(), any()) } returns result } + fun withDetectedLinkPreviewTarget(result: LinkPreviewTarget?) = apply { + every { detectLinkPreviewTarget(any(), any()) } returns result + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index b9e1186c089..8586f235abf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -39,6 +39,7 @@ import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.upload.AssetUploadParams import com.wire.kalium.logic.feature.asset.upload.ScheduleNewAssetMessageResult +import com.wire.kalium.logic.feature.message.linkpreview.LinkPreviewTarget import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.Dispatchers @@ -82,6 +83,7 @@ class SendMessageViewModelTest { val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withGeneratedLinkPreviewResult(preview) + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 0)) .arrange() viewModel.updateLinkPreview("https://wire.com", emptyList()) @@ -134,6 +136,7 @@ class SendMessageViewModelTest { .withSuccessfulViewModelInit() .withGeneratedLinkPreviewResult(preview) .withSuccessfulSendTextMessage() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 0)) .arrange() viewModel.updateLinkPreview("https://wire.com", emptyList()) @@ -154,7 +157,46 @@ class SendMessageViewModelTest { coVerify(exactly = 1) { arrangement.sendTextMessage( conversationId = conversationId, - text = "https://wire.com", + text = "", + linkPreviews = listOf(preview), + mentions = emptyList(), + quotedMessageId = null + ) + } + } + + @Test + fun `given prefetched preview snapshot when live preview state is cleared then sending reuses snapshot`() = runTest { + val preview = MessageLinkPreview( + url = "https://wire.com", + urlOffset = 0, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withSuccessfulSendTextMessage() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 0)) + .arrange() + + viewModel.clearLinkPreview() + + viewModel.trySendMessage( + ComposableMessageBundle.SendTextMessageBundle( + conversationId = conversationId, + message = "https://wire.com", + mentions = emptyList(), + prefetchedLinkPreview = preview, + ) + ) + advanceUntilIdle() + + coVerify(exactly = 0) { + arrangement.generateLinkPreview(any(), any()) + } + coVerify { + arrangement.sendTextMessage( + conversationId = conversationId, + text = "", linkPreviews = listOf(preview), mentions = emptyList(), quotedMessageId = null @@ -173,6 +215,7 @@ class SendMessageViewModelTest { .withSuccessfulViewModelInit() .withGeneratedLinkPreviewResult(preview) .withSuccessfulSendTextMessage() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 1)) .arrange() viewModel.updateLinkPreview(" https://wire.com ", emptyList()) @@ -210,6 +253,7 @@ class SendMessageViewModelTest { .withSuccessfulViewModelInit() .withGeneratedLinkPreviewResult(preview) .withSuccessfulSendTextMessage() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 6)) .arrange() viewModel.updateLinkPreview(message, emptyList()) From 3727edd13a07d2d8e21ae63ad818e5de32ecb2a7 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 8 Jul 2026 16:49:03 +0200 Subject: [PATCH 15/18] feat: cleanup --- .../home/conversations/ConversationScreen.kt | 4 +- .../messages/item/MessageContentAndStatus.kt | 88 +++++-------------- .../home/conversations/model/MessageTypes.kt | 2 - .../sendmessage/SendMessageViewModel.kt | 6 +- .../home/messagecomposer/LinkPreviewCard.kt | 26 +++--- .../SendMessageViewModelArrangement.kt | 1 - .../sendmessage/SendMessageViewModelTest.kt | 3 +- kalium | 2 +- 8 files changed, 43 insertions(+), 89 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 13a16ffe409..2a835d6ae20 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 @@ -445,7 +445,9 @@ fun ConversationScreen( NavigationCommand(MessageDetailsScreenDestination(conversationInfoViewModel.conversationId, messageId, isSelfMessage)) ) }, - onSendMessage = { sendMessageViewModel.trySendMessage(it.withPrefetchedLinkPreview(sendMessageViewModel.currentLinkPreview)) }, + onSendMessage = { + sendMessageViewModel.trySendMessage(it.withPrefetchedLinkPreview(sendMessageViewModel.currentLinkPreview)) + }, onPingOptionClicked = { if (conversationCallViewModel.conversationCallViewState.participantsCount > MAX_GROUP_SIZE_FOR_PING) { showDialog.value = ConversationScreenDialogType.PING_CONFIRMATION diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index d93da4051f1..5cee8f49a31 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -215,10 +215,31 @@ private fun MessageContent( } is UIMessageContent.TextMessage -> { - if (message.linkPreviews.isEmpty()) { - Column { + val hasLinkPreview = message.linkPreviews.isNotEmpty() + Column { + message.linkPreviews.firstOrNull()?.let { preview -> + LinkPreviewCard( + preview = preview, + isAvailable = !message.isPending && message.isAvailable, + messageStyle = messageStyle, + onClick = { onLinkClick(preview.url) } + ) + } + Column( + modifier = if (hasLinkPreview) { + Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing8x + ) + } else { + Modifier + } + ) { messageContent.messageBody.quotedMessage?.let { - VerticalSpace.x4() + if (!hasLinkPreview) { + VerticalSpace.x4() + } when (it) { is UIQuotedMessage.UIQuotedData -> QuotedMessage( conversationId = message.conversationId, @@ -253,67 +274,8 @@ private fun MessageContent( onLinkClick = onLinkClick, messageStyle = messageStyle, accent = accent, - linkPreviews = message.linkPreviews ) } - } else { - Column { - message.linkPreviews.takeIf { it.isNotEmpty() }?.let { previews -> - LinkPreviewCard( - preview = previews.first(), - isAvailable = !message.isPending && message.isAvailable, - messageStyle = messageStyle, - onClick = { onLinkClick(previews.first().url) } - ) - } - - Column( - modifier = Modifier.padding( - start = dimensions().spacing10x, - end = dimensions().spacing10x, - top = dimensions().spacing8x - ) - ) { - messageContent.messageBody.quotedMessage?.let { - when (it) { - is UIQuotedMessage.UIQuotedData -> QuotedMessage( - conversationId = message.conversationId, - messageData = it, - style = QuotedMessageStyle( - quotedStyle = QuotedStyle.COMPLETE, - messageStyle = messageStyle, - selfAccent = accent, - senderAccent = it.senderAccent - ), - clickable = onReplyClick - ) - - UIQuotedMessage.UnavailableData -> QuotedUnavailable( - style = QuotedMessageStyle( - quotedStyle = QuotedStyle.COMPLETE, - messageStyle = messageStyle, - selfAccent = accent, - senderAccent = Accent.Unknown - ) - ) - } - VerticalSpace.x4() - } - - MessageBody( - messageBody = messageContent.messageBody, - searchQuery = searchQuery, - isAvailable = !message.isPending && message.isAvailable, - onOpenProfile = onOpenProfile, - buttonList = null, - messageId = message.header.messageId, - onLinkClick = onLinkClick, - messageStyle = messageStyle, - accent = accent, - linkPreviews = message.linkPreviews - ) - } - } } } @@ -354,7 +316,6 @@ private fun MessageContent( onLinkClick = onLinkClick, messageStyle = messageStyle, accent = accent, - linkPreviews = message.linkPreviews ) } } @@ -478,7 +439,6 @@ private fun MessageContent( onLinkClick = onLinkClick, messageStyle = messageStyle, accent = accent, - linkPreviews = message.linkPreviews ) Spacer(modifier = Modifier.height(dimensions().spacing8x)) } 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 33f410c8556..72d8906b83b 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 @@ -88,7 +88,6 @@ 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 com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import kotlinx.collections.immutable.PersistentList import okio.Path @@ -106,7 +105,6 @@ internal fun MessageBody( searchQuery: String = "", clickable: Boolean = true, messageStyle: MessageStyle = MessageStyle.NORMAL, - linkPreviews: List = emptyList() ) { val resources = LocalContext.current.resources val configuration = LocalConfiguration.current diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 31825917866..1d282af9a8a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -50,7 +50,6 @@ import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.getVideoMetaData import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either -import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AttachmentType @@ -554,6 +553,7 @@ class SendMessageViewModel( sureAboutMessagingDialogState = SureAboutMessagingDialogState.Hidden } + @Suppress("ReturnCount") fun updateLinkPreview(text: String, mentions: List) { val nextTarget = detectLinkPreviewTarget(text, mentions) val currentTarget = activeLinkPreviewTarget @@ -601,6 +601,7 @@ class SendMessageViewModel( currentLinkPreview = null } + @Suppress("ReturnCount") private suspend fun resolveLinkPreviewsForSend( text: String, mentions: List, @@ -620,10 +621,11 @@ class SendMessageViewModel( ?: emptyList() } - val generatedPreview = generateLinkPreview(text = text, mentions = mentions) + val generatedPreview = withContext(dispatchers.io()) { generateLinkPreview(text = text, mentions = mentions) } return generatedPreview?.let(::listOf) ?: emptyList() } + @Suppress("ReturnCount") private fun String.removeStandalonePreviewUrl( target: LinkPreviewTarget?, hasPreview: Boolean, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt index 2c4e75f5a04..f9b0c4ec591 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import com.wire.android.ui.common.colorsScheme @@ -50,21 +51,7 @@ fun LinkPreviewCard( messageStyle: MessageStyle = MessageStyle.NORMAL, onClick: (() -> Unit)? = null, ) { - - val color = when (messageStyle) { - MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary - MessageStyle.BUBBLE_OTHER -> when { - isAvailable -> colorsScheme().otherBubble.onPrimary - else -> MaterialTheme.wireColorScheme.secondaryText - } - - MessageStyle.NORMAL -> { - when { - isAvailable -> MaterialTheme.colorScheme.onBackground - else -> MaterialTheme.wireColorScheme.secondaryText - } - } - } + val color = linkPreviewTextColor(messageStyle, isAvailable) Column( modifier = Modifier .fillMaxWidth() @@ -133,10 +120,17 @@ fun LinkPreviewCard( ) ) } - } } +@Composable +private fun linkPreviewTextColor(messageStyle: MessageStyle, isAvailable: Boolean): Color = + when (messageStyle) { + MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary + MessageStyle.BUBBLE_OTHER -> if (isAvailable) colorsScheme().otherBubble.onPrimary else MaterialTheme.wireColorScheme.secondaryText + MessageStyle.NORMAL -> if (isAvailable) MaterialTheme.colorScheme.onBackground else MaterialTheme.wireColorScheme.secondaryText + } + private val MessageLinkPreview.displayHost: String? get() { val link = permanentUrl ?: url diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index e34afb5018f..7efed61dc5f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -60,7 +60,6 @@ import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every -import io.mockk.firstArg import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index 8586f235abf..d76016ceaa4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -83,7 +83,6 @@ class SendMessageViewModelTest { val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withGeneratedLinkPreviewResult(preview) - .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 0)) .arrange() viewModel.updateLinkPreview("https://wire.com", emptyList()) @@ -208,7 +207,7 @@ class SendMessageViewModelTest { fun `given standalone link with preview when sending then raw link is removed from outgoing text`() = runTest { val preview = MessageLinkPreview( url = "https://wire.com", - urlOffset = 0, + urlOffset = 1, title = "Wire" ) val (arrangement, viewModel) = SendMessageViewModelArrangement() diff --git a/kalium b/kalium index d33dd63ce35..df00b7fee87 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d33dd63ce35f874c6122baf8e7b5cae6309958cc +Subproject commit df00b7fee87079e65c583600c2eec0365cead74f From 2db49739aeb3fdf0a60e66c34b77a9c1713f5051 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Wed, 8 Jul 2026 17:18:04 +0200 Subject: [PATCH 16/18] feat: cleanup --- .../home/conversations/messages/item/MessageBubbleItem.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt index 06f0ce29861..2b18423e558 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt @@ -216,7 +216,11 @@ fun MessageBubbleItem( Column { val contentModifier = if (message.hasAssetParams) { Modifier.padding( - bottom = if (useSmallBottomPadding) dimensions().spacing0x else paddingValue + bottom = if (useSmallBottomPadding && message.linkPreviewParams == null) { + dimensions().spacing0x + } else { + paddingValue + } ) } else { Modifier.padding(all = paddingValue) From 0c2ac29a341cb879a323f4a93649b058ea951db2 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Thu, 9 Jul 2026 15:32:13 +0200 Subject: [PATCH 17/18] feat: stability and kalium ref --- app/stability/app-devDebug.stability | 37 ++++++++++++++++++++++++---- kalium | 2 +- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index c50c7a27e7e..45d4e8bd57a 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -1362,24 +1362,24 @@ public fun com.wire.android.ui.authentication.login.WireAuthBackgroundLayout(mod - content: STABLE (composable function type) @Composable -public fun com.wire.android.ui.authentication.login.email.ForgotPasswordLabel(forgotPasswordUrl: kotlin.String, textColor: androidx.compose.ui.graphics.Color, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.authentication.login.email.ForgotPasswordLabel(forgotPasswordUrl: kotlin.String, modifier: androidx.compose.ui.Modifier, textColor: androidx.compose.ui.graphics.Color): kotlin.Unit skippable: true restartable: true params: - forgotPasswordUrl: STABLE (String is immutable) - - textColor: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) + - textColor: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.authentication.login.email.LoginButton(loading: kotlin.Boolean, enabled: kotlin.Boolean, text: kotlin.String, loadingText: kotlin.String, modifier: androidx.compose.ui.Modifier, onClick: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.authentication.login.email.LoginButton(loading: kotlin.Boolean, enabled: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, text: kotlin.String, loadingText: kotlin.String, onClick: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: - loading: STABLE (primitive type) - enabled: STABLE (primitive type) + - modifier: STABLE (marked @Stable or @Immutable) - text: STABLE (String is immutable) - loadingText: STABLE (String is immutable) - - modifier: STABLE (marked @Stable or @Immutable) - onClick: STABLE (function type) @Composable @@ -8328,6 +8328,16 @@ private fun com.wire.android.ui.home.messagecomposer.ItalicButton(onRichTextItal - onRichTextItalicButtonClicked: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +public fun com.wire.android.ui.home.messagecomposer.LinkPreviewCard(preview: com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview, isAvailable: kotlin.Boolean, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, onClick: kotlin.Function0?): kotlin.Unit + skippable: false + restartable: true + params: + - preview: UNSTABLE (has mutable properties or unstable members) + - isAvailable: STABLE (primitive type) + - messageStyle: STABLE (class with no mutable properties) + - onClick: STABLE (function type) + @Composable public fun com.wire.android.ui.home.messagecomposer.MembersMentionList(membersToMention: kotlin.collections.List, searchQuery: kotlin.String, onMentionPicked: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false @@ -8498,6 +8508,14 @@ private fun com.wire.android.ui.home.messagecomposer.buildAttachmentOptionItems( - onLocationPickerClicked: STABLE (function type) - onPermissionPermanentlyDenied: STABLE (function type) +@Composable +private fun com.wire.android.ui.home.messagecomposer.linkPreviewTextColor(messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, isAvailable: kotlin.Boolean): androidx.compose.ui.graphics.Color + skippable: true + restartable: true + params: + - messageStyle: STABLE (class with no mutable properties) + - isAvailable: STABLE (primitive type) + @Composable private fun com.wire.android.ui.home.messagecomposer.location.LoadingLocation(): kotlin.Unit skippable: true @@ -9771,7 +9789,7 @@ public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsConfigScreen - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsScreenContent(isAnonymousUsageDataEnabled: kotlin.Boolean, shouldShowAnalyticsUsage: kotlin.Boolean, areReadReceiptsEnabled: kotlin.Boolean, setReadReceiptsState: kotlin.Function1, isTypingIndicatorEnabled: kotlin.Boolean, setTypingIndicatorState: kotlin.Function1, screenshotCensoringConfig: com.wire.android.ui.home.settings.privacy.ScreenshotCensoringConfig, setScreenshotCensoringConfig: kotlin.Function1, setAnonymousUsageDataEnabled: kotlin.Function1, onBackPressed: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsScreenContent(isAnonymousUsageDataEnabled: kotlin.Boolean, shouldShowAnalyticsUsage: kotlin.Boolean, areReadReceiptsEnabled: kotlin.Boolean, setReadReceiptsState: kotlin.Function1, areLinkPreviewsEnabled: kotlin.Boolean, setLinkPreviewsState: kotlin.Function1, isTypingIndicatorEnabled: kotlin.Boolean, setTypingIndicatorState: kotlin.Function1, screenshotCensoringConfig: com.wire.android.ui.home.settings.privacy.ScreenshotCensoringConfig, setScreenshotCensoringConfig: kotlin.Function1, setAnonymousUsageDataEnabled: kotlin.Function1, onBackPressed: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: @@ -9779,6 +9797,8 @@ public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsScreenConten - shouldShowAnalyticsUsage: STABLE (primitive type) - areReadReceiptsEnabled: STABLE (primitive type) - setReadReceiptsState: STABLE (function type) + - areLinkPreviewsEnabled: STABLE (primitive type) + - setLinkPreviewsState: STABLE (function type) - isTypingIndicatorEnabled: STABLE (primitive type) - setTypingIndicatorState: STABLE (function type) - screenshotCensoringConfig: STABLE (class with no mutable properties) @@ -10207,6 +10227,13 @@ public fun com.wire.android.ui.markdown.MarkdownThematicBreak(messageStyle: com. - messageStyle: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.ui.markdown.getMentions(onOpenProfile: kotlin.Function1<@[ParameterName(name = \): kotlin.collections.List + skippable: true + restartable: true + params: + - onOpenProfile: STABLE (function type) + @Composable public fun com.wire.android.ui.miscViewModel(): VM of com.wire.android.ui.miscViewModel skippable: true diff --git a/kalium b/kalium index df00b7fee87..1a617da86fb 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit df00b7fee87079e65c583600c2eec0365cead74f +Subproject commit 1a617da86fbf53ac55d6acd489151009603d35f2 From b43a291bf80a5d044b2a920841f1478728bb8e92 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Fri, 10 Jul 2026 11:39:14 +0200 Subject: [PATCH 18/18] kalium re --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 1a617da86fb..0e9adecc09b 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 1a617da86fbf53ac55d6acd489151009603d35f2 +Subproject commit 0e9adecc09b6b19a03f02ea3467f0255cd5d11ec