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 db66f120510..8dd2c3f836d 100644 --- a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt @@ -62,6 +62,7 @@ class KaliumConfigsModule { domainWithFaultyKeysMap = BuildConfig.DOMAIN_REMOVAL_KEYS_FOR_REPAIR, isDebug = BuildConfig.DEBUG, blockCrossBackendLogin = BuildConfig.BLOCK_CROSS_BACKEND_LOGIN, + linkPreviewEnabled = BuildConfig.LINK_PREVIEW_ENABLED, ) } } 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 fba733984a9..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,8 @@ 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 import dev.zacsweers.metro.BindingContainer @@ -102,6 +104,14 @@ class MessageModule { @Provides fun provideSendTextMessageUseCase(messageScope: MessageScope): SendTextMessageUseCase = messageScope.sendTextMessage + @Provides + 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/di/accountScoped/UserModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt index 37a092eeb38..a937009187a 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 @@ -57,6 +57,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 @@ -116,6 +118,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/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 6ed92f5f91d..ac8c36616bb 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/ConversationCoreViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt index c73765df445..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,8 @@ 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 import com.wire.kalium.network.NetworkStateObserver @@ -197,6 +199,8 @@ class ConversationCoreViewModelFactory @Inject constructor( private val getWireCellsConfig: GetWireCellConfigurationUseCase, 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, ) { @@ -266,6 +270,8 @@ class ConversationCoreViewModelFactory @Inject constructor( analyticsManager = analyticsManager, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, sharedState = messageSharedState, + 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 6816949e7bd..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 @@ -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( @@ -420,7 +445,9 @@ 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 @@ -761,6 +788,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/messages/item/MessageBubbleItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt index 074448d7ea4..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 @@ -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 } @@ -201,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) 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 91214130249..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 @@ -36,8 +36,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 @@ -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 @@ -214,44 +215,67 @@ private fun MessageContent( } is UIMessageContent.TextMessage -> { + val hasLinkPreview = message.linkPreviews.isNotEmpty() 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 + 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 { + if (!hasLinkPreview) { + 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, + ) } - 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 - ) } } @@ -291,7 +315,7 @@ private fun MessageContent( messageId = message.header.messageId, onLinkClick = onLinkClick, messageStyle = messageStyle, - accent = accent + accent = accent, ) } } @@ -414,7 +438,7 @@ private fun MessageContent( messageId = message.header.messageId, onLinkClick = onLinkClick, messageStyle = messageStyle, - accent = accent + accent = accent, ) Spacer(modifier = Modifier.height(dimensions().spacing8x)) } 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 ecbee74eaea..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 @@ -104,7 +104,7 @@ internal fun MessageBody( onLinkClick: (String) -> Unit, searchQuery: String = "", clickable: Boolean = true, - messageStyle: MessageStyle = MessageStyle.NORMAL + messageStyle: MessageStyle = MessageStyle.NORMAL, ) { val resources = LocalContext.current.resources val configuration = LocalConfiguration.current @@ -156,6 +156,7 @@ internal fun MessageBody( clickable ) } + 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 0ec5d9b5265..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 @@ -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 @@ -94,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/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 1ae9dd7a0a7..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 @@ -75,12 +75,19 @@ 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 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( @@ -106,7 +113,9 @@ class SendMessageViewModel( private val removeMessageDraft: RemoveMessageDraftUseCase, private val analyticsManager: AnonymousAnalyticsManager, private val isWireCellsEnabledForConversation: IsWireCellsEnabledForConversationUseCase, - private val sharedState: MessageSharedState + private val sharedState: MessageSharedState, + private val generateLinkPreview: GenerateLinkPreviewUseCase, + private val detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase, ) : ViewModel() { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -123,6 +132,12 @@ class SendMessageViewModel( SureAboutMessagingDialogState.Hidden ) + var currentLinkPreview: MessageLinkPreview? by mutableStateOf(null) + private set + + private var activeLinkPreviewTarget: LinkPreviewTarget? = null + private var activeLinkPreviewRequest: Job? = null + init { conversationNavArgs.pendingTextBundle?.let { text -> trySendPendingMessageBundle(text) @@ -263,9 +278,23 @@ class SendMessageViewModel( removeMessageDraft(messageBundle.conversationId) sendTypingEvent(messageBundle.conversationId, TypingIndicatorMode.STOPPED) with(messageBundle) { + val linkPreviews = resolveLinkPreviewsForSend( + text = message, + mentions = mentions.map { it.intoMessageMention() }, + prefetchedLinkPreview = prefetchedLinkPreview, + ) + val textToSend = message.removeStandalonePreviewUrl( + target = detectLinkPreviewTarget( + 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 ).toEither() @@ -524,6 +553,99 @@ class SendMessageViewModel( sureAboutMessagingDialogState = SureAboutMessagingDialogState.Hidden } + @Suppress("ReturnCount") + fun updateLinkPreview(text: String, mentions: List) { + val nextTarget = detectLinkPreviewTarget(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 + } + + @Suppress("ReturnCount") + private suspend fun resolveLinkPreviewsForSend( + text: String, + mentions: List, + prefetchedLinkPreview: MessageLinkPreview?, + ): List { + 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) + ?.let(::listOf) + ?: emptyList() + } + + 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, + ): 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 + } + } + 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..f9b0c4ec591 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/LinkPreviewCard.kt @@ -0,0 +1,154 @@ +/* + * 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 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 +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 +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, + isAvailable: Boolean = true, + messageStyle: MessageStyle = MessageStyle.NORMAL, + onClick: (() -> Unit)? = null, +) { + val color = linkPreviewTextColor(messageStyle, isAvailable) + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = onClick != null, + onClick = { onClick?.invoke() } + ) + ) { + 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 = color, + 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.wireTypography.body05, + color = color, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = dimensions().spacing10x, + end = dimensions().spacing10x, + top = dimensions().spacing4x, + bottom = dimensions().spacing10x + ) + ) + } + 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 + ) + ) + } + } +} + +@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 + val host = runCatching { Uri.parse(link).host }.getOrNull() + return host + ?.removePrefix("www.") + ?.takeIf { it.isNotBlank() } + } + +@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/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/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 a32965013d3..977772ff578 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/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index d5326afae15..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 @@ -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 @@ -52,6 +53,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.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -83,6 +87,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 @@ -159,6 +176,12 @@ internal class SendMessageViewModelArrangement { @MockK lateinit var sharedState: MessageSharedState + @MockK + lateinit var generateLinkPreview: GenerateLinkPreviewUseCase + + @MockK + lateinit var detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase + private val viewModel by lazy { SendMessageViewModel( sendTextMessage = sendTextMessage, @@ -183,7 +206,9 @@ internal class SendMessageViewModelArrangement { analyticsManager = analyticsManager, sendMultipartMessage = sendMultipartMessage, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, - sharedState = sharedState + sharedState = sharedState, + generateLinkPreview = generateLinkPreview, + detectLinkPreviewTarget = detectLinkPreviewTarget ) } @@ -297,5 +322,13 @@ internal class SendMessageViewModelArrangement { coEvery { isWireCellsEnabledForConversation.invoke(conversationId) } returns result } + fun withGeneratedLinkPreviewResult(result: MessageLinkPreview?) = apply { + 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 23c5ad8bf84..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 @@ -35,9 +35,11 @@ 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 +import com.wire.kalium.logic.feature.message.linkpreview.LinkPreviewTarget import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.Dispatchers @@ -71,6 +73,211 @@ 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() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 0)) + .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 = "", + 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 + ) + } + } + + @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 = 1, + title = "Wire" + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withGeneratedLinkPreviewResult(preview) + .withSuccessfulSendTextMessage() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 1)) + .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() + .withDetectedLinkPreviewTarget(LinkPreviewTarget("https://wire.com", 6)) + .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 { 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/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index 1ed5687f9e5..1daa5ae97d6 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -8296,6 +8296,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 @@ -8466,6 +8476,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 @@ -9739,7 +9757,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: @@ -9747,6 +9765,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) diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index 1530214fabc..ca22cd97fd3 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -162,4 +162,6 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { BLOCK_CROSS_BACKEND_LOGIN("block_cross_backend_login", ConfigType.BOOLEAN), PENDING_MESSAGES("pending_messages", ConfigType.BOOLEAN), + + LINK_PREVIEW_ENABLED("link_preview_enabled", ConfigType.BOOLEAN), } diff --git a/default.json b/default.json index 04b20077cb3..b233f10838b 100644 --- a/default.json +++ b/default.json @@ -42,7 +42,8 @@ "conversation_feeder_enabled": true, "db_invalidation_control_enabled": true, "enforce_configuration_signature": false, - "pending_messages": true + "pending_messages": true, + "link_preview_enabled": true }, "staging": { "application_id": "com.waz.zclient.dev", @@ -78,7 +79,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", @@ -97,7 +99,8 @@ "phone_landscape_enabled": true, "conversation_feeder_enabled": true, "db_invalidation_control_enabled": false, - "pending_messages": true + "pending_messages": true, + "link_preview_enabled": true }, "fdroid": { "application_id": "com.wire", @@ -186,5 +189,6 @@ "call_reactions_enabled": true, "nomad_profiles_enabled": true, "block_cross_backend_login": false, - "pending_messages": false + "pending_messages": false, + "link_preview_enabled": false } diff --git a/kalium b/kalium index 22bf2fea615..0e9adecc09b 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 22bf2fea6152bf87d5b25ceb2b5ae0acf54b4e89 +Subproject commit 0e9adecc09b6b19a03f02ea3467f0255cd5d11ec