diff --git a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt new file mode 100644 index 00000000000..6333b4c156d --- /dev/null +++ b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.api + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState + +/** + * Service to perform security scans in the contents for a given event and media source. + * + * The default implementation does nothing, unless a [ContentScanner] is provided when building the client. + */ +fun interface ContentScannerService { + fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt new file mode 100644 index 00000000000..5e029d7a768 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl + +import io.element.android.features.contentscanner.api.ContentScannerService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.io.IOException + +/** + * Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources. + * + * When a scan is requested for a given event and media source, it'll check if a scan is really needed (the media source has not been scanned before), + * then it will use a per-event mutex to avoid performing concurrent scans for thumbnail and full-size media sources. + * + * If, before starting a new scan, the cached result is already a failure (either we're scanning the full media source and the thumbnail already failed + * or the other way around), it will not perform the scan again. + */ +class DefaultContentScannerService( + private val contentScanner: ContentScanner, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, +) : ContentScannerService { + override fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) { + for (mediaSource in mediaSources) { + sessionCoroutineScope.launch(coroutineDispatchers.io.limitedParallelism(4)) { + val url = mediaSource.safeUrl + val currentState = contentValidationState.getMediaStateFlow(url).first() + if (currentState != ContentValidationValue.Unknown) return@launch + contentValidationState.update(url, ContentValidationValue.Loading) + contentScanner.scan(mediaSource) + .onSuccess { isValid -> + val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid + contentValidationState.update(url, contentValidationValue) + } + .onFailure { exception -> + if (exception is IOException) { + // If it's an IO-related exception, we can retry later, so we don't cache it + contentValidationState.update(url, ContentValidationValue.Unknown) + } else { + // For other exceptions, we cache the failure + contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception)) + } + } + } + } + } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt new file mode 100644 index 00000000000..73cd8c08574 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultEventContentValidationCache : EventContentValidationCache { + private val cache = ConcurrentHashMap() + + override operator fun get(eventId: EventId): ContentValidationState { + return cache.getOrPut(eventId) { DefaultContentValidationState() } + } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt new file mode 100644 index 00000000000..ec28103ceb0 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.features.contentscanner.api.ContentScannerService +import io.element.android.features.contentscanner.impl.DefaultContentScannerService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import kotlinx.coroutines.CoroutineScope + +@BindingContainer +@ContributesTo(RoomScope::class) +object ContentScannerModule { + @Provides + @SingleIn(RoomScope::class) + fun providesContentScannerService( + contentScanner: ContentScanner?, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, + coroutineDispatchers: CoroutineDispatchers, + ): ContentScannerService { + return if (contentScanner != null) { + DefaultContentScannerService( + contentScanner = contentScanner, + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = coroutineDispatchers, + ) + } else { + NoopContentScannerService() + } + } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt new file mode 100644 index 00000000000..4fa267ded03 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl.di + +import io.element.android.features.contentscanner.api.ContentScannerService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue + +/** + * Noop implementation of [ContentScannerService] that always returns success. + * + * This is used when the content scanner feature is not enabled or available. + */ +class NoopContentScannerService : ContentScannerService { + override fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) { + // Always return success for the noop implementation + for (mediaSource in mediaSources) { + val url = mediaSource.safeUrl + contentValidationState.update(url, ContentValidationValue.Valid) + } + } +} diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 2661f7e3306..116a3a7c41b 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) implementation(projects.features.networkmonitor.api) + implementation(projects.features.contentscanner.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index cbab26fe549..88a90f6c7db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.blurHash import io.element.android.features.messages.impl.timeline.model.event.duration import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollMode @@ -155,6 +156,7 @@ class MessagesFlowNode( val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canUseOverlay: Boolean, + val blurHash: String?, ) : NavTarget @Parcelize @@ -377,6 +379,7 @@ class MessagesFlowNode( mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, + blurHash = navTarget.blurHash, ) val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() { @@ -828,6 +831,7 @@ class MessagesFlowNode( mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, type = galleryItem.type.toMediaViewerType(), + blurHash = galleryItem.blurhash, ) }.reversed() NavTarget.GalleryViewer( @@ -861,6 +865,7 @@ class MessagesFlowNode( mediaSource = attachment.mediaSource, thumbnailSource = attachment.thumbnailSource, type = GalleryItemData.Type.File, + blurHash = null, ) }.reversed() NavTarget.GalleryViewer( @@ -920,6 +925,7 @@ class MessagesFlowNode( mediaSource = mediaSource, thumbnailSource = thumbnailSource, canUseOverlay = canUseOverlay, + blurHash = content.blurHash(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index a7e88460473..18c02d8f406 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -66,6 +66,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.ui.strings.CommonStrings @@ -97,6 +99,7 @@ class MessagesNode( private val permalinkParser: PermalinkParser, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, + private val eventContentValidationCache: EventContentValidationCache, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val focusedEventId: EventId?, @@ -261,6 +264,7 @@ class MessagesNode( val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + LocalEventContentValidationState provides eventContentValidationCache, ) { val state = presenter.present() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 8887424dc6c..ec014a4ff61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -123,6 +123,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link @@ -148,6 +150,8 @@ fun MessagesView( forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, ) { + val eventContentValidationState = LocalEventContentValidationState.current + OnLifecycleEvent { _, event -> state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event)) } @@ -170,6 +174,9 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") + val eventId = event.eventId ?: return + if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return + val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { localView.hideKeyboard() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index eaa00f427e3..80dd21b7b38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.location.api.LocationService import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.attachments.Attachment @@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.getDirectRoomMember import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.content +import io.element.android.libraries.matrix.ui.messages.reply.eventId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider @@ -135,6 +140,8 @@ class MessageComposerPresenter( private val notificationConversationService: NotificationConversationService, private val slashCommandService: SlashCommandService, private val featureFlagService: FeatureFlagService, + private val contentScannerService: ContentScannerService, + private val contentValidationCache: EventContentValidationCache, ) : Presenter { @AssistedFactory interface Factory { @@ -848,6 +855,11 @@ class MessageComposerPresenter( setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true) } else -> { + if (currentComposerMode is MessageComposerMode.Reply) { + val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch + val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()] + contentScannerService.scan(currentComposerMode.replyToDetails.eventId(), mediaSources, contentValidationState) + } // When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario. if (currentComposerMode.isEditing) { setText("", markdownTextEditorState, richTextEditorState) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index b4fe504ab17..bdad9ba6310 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule @@ -290,9 +289,9 @@ private fun TimelineItemEventContentViewWrapper( ) } else { TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onGalleryItemClick = onGalleryItemClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 5330fc00d35..cfc3672ca38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import kotlin.time.Duration sealed interface TimelineEvent { @@ -61,4 +63,10 @@ sealed interface TimelineEvent { ) : TimelineItemPollEvent data object StopLiveLocationShare : TimelineItemEvent + + data class ValidateMedia( + val eventId: EventId, + val mediaSources: List, + val validationState: ContentValidationState + ) : TimelineItemEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 00039dc1af6..9a33299cd04 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -35,6 +35,8 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -64,6 +66,7 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analyticsproviders.api.AnalyticsUserData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -98,6 +101,7 @@ class TimelinePresenter( private val analyticsService: AnalyticsService, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val markAsFullyRead: MarkAsFullyRead, + private val timelineProtectionPresenter: Presenter, ) : Presenter { private val tag = "TimelinePresenter" @@ -157,6 +161,8 @@ class TimelinePresenter( value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) } + val timelineProtectionState = timelineProtectionPresenter.present() + fun handleEvent(event: TimelineEvent) { when (event) { is TimelineEvent.LoadMore -> { @@ -252,6 +258,9 @@ class TimelinePresenter( focusedEventId = event.focusedEvent, ) } + is TimelineEvent.ValidateMedia -> { + timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSources, event.validationState)) + } } } @@ -259,7 +268,7 @@ class TimelinePresenter( timelineItemsFactory.timelineItems .onEach { newTimelineItems -> timelineItemIndexer.process(newTimelineItems) - timelineItems = newTimelineItems + timelineItems = newTimelineItems.toImmutableList() analyticsService.run { finishLongRunningTransaction(DisplayFirstTimelineItems) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 80bd342d018..1ebdc34c6c4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache @@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.LayoutDirection @@ -62,6 +66,8 @@ fun MessageEventBubble( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + customBackgroundColor: Color? = null, + borderColor: Color? = null, content: @Composable BoxScope.() -> Unit = {}, ) { val clickableModifier = if (isTalkbackActive()) { @@ -79,10 +85,12 @@ fun MessageEventBubble( val cutTopStart = state.cutTopStart // Ignore state.isHighlighted for now, we need a design decision on it. - val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine) + val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)) val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) } val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() + + val updatedBorderColor by rememberUpdatedState(borderColor) BoxWithConstraints( modifier = modifier .drawWithCache { @@ -103,6 +111,9 @@ fun MessageEventBubble( // Then draw the content on top of it drawContent() + // Draw border color, if any + updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) } + // And then clip the top start corner if needed to make room for the avatar if (cutTopStart) { drawCircle( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index c73248af532..26e041e367e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -33,9 +33,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -88,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.mustBeProtected import io.element.android.libraries.architecture.AsyncData @@ -122,9 +125,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName +import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView +import io.element.android.libraries.matrix.ui.messages.reply.content import io.element.android.libraries.matrix.ui.messages.reply.eventId import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode @@ -176,12 +183,12 @@ fun TimelineItemEventRow( val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + timelineProtectionState = timelineProtectionState, onContentClick = onContentClick, onGalleryItemClick = onGalleryItemClick, onLongClick = onLongClick, - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, @@ -468,6 +475,24 @@ private fun TimelineItemEventRowContent( ) } + val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia) + .collectOverallState() + val needsInvalidContentCustomisations = + // Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed + event.content !is TimelineItemGalleryContent && + event.content !is TimelineItemAttachmentsContent && + currentContentValidationState.isInvalid() + + // If the event has a dangerous media content without a preview, we need to set a custom message bubble background color + val themeColors = ElementTheme.colors + val dangerousContentBubbleColor = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) { + themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations && event.content.isMedia } + } + + val borderColor = remember(themeColors.isLight, needsInvalidContentCustomisations) { + themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations } + } + // Message bubble val bubbleState = BubbleState( groupPosition = event.groupPosition, @@ -494,6 +519,8 @@ private fun TimelineItemEventRowContent( interactionSource = interactionSource, onClick = onContentClick, onLongClick = onLongClick, + customBackgroundColor = dangerousContentBubbleColor, + borderColor = borderColor, ) { MessageEventBubbleContent( event = event, @@ -770,10 +797,13 @@ private fun MessageEventBubbleContent( } val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> + val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content()) + .collectOverallState() val topPadding = if (showThreadDecoration) 0.dp else 8.dp + val shape = RoundedCornerShape(6.dp) val inReplyToModifier = Modifier .padding(top = topPadding, start = 8.dp, end = 8.dp) - .clip(RoundedCornerShape(6.dp)) + .clip(shape) val talkbackCompatModifier = if (isTalkbackActive()) { // Use z-index to make the replied to text being read after the message @@ -782,14 +812,25 @@ private fun MessageEventBubbleContent( } else { inReplyToModifier.clickable(onClick = inReplyToClick) } + + val isContentInvalid = currentContentValidationState.isInvalid() + val borderColor = if (isContentInvalid) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary + val backgroundColor = if (isContentInvalid) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault Box( modifier = talkbackCompatModifier - .border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) - .background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp)) + .border(1.dp, borderColor, shape) + .background(backgroundColor, shape) .padding(4.dp) ) { + val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content()) + val updatedEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(inReplyTo) { + val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect + updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState)) + } InReplyToView( inReplyTo = inReplyTo, + contentValidationValue = currentContentValidationState, hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), ) } @@ -809,22 +850,35 @@ private fun MessageEventBubbleContent( } } - val timestampPosition = when (val content = event.content) { - is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below - is TimelineItemAttachmentsContent -> TimestampPosition.Below - is TimelineItemStickerContent -> TimestampPosition.Overlay - is TimelineItemLocationContent -> { - val content = content.ensureActiveLiveLocation() - val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && - content.mode.isActive && - content.mode.isOwnUser - if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay + val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState() + val needsInvalidContentLayout = + // Gallery events should not apply custom paddings or layout dispositions + event.content !is TimelineItemGalleryContent && + event.content !is TimelineItemAttachmentsContent && + contentValidationState.isInvalid() + + val timestampPosition = if (needsInvalidContentLayout) { + // The invalid content view will be displayed in all these cases, independent of the event content + TimestampPosition.Aligned + } else { + when (val content = event.content) { + is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below + is TimelineItemAttachmentsContent -> TimestampPosition.Below + is TimelineItemStickerContent -> TimestampPosition.Overlay + is TimelineItemLocationContent -> { + val content = content.ensureActiveLiveLocation() + val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && + content.mode.isActive && + content.mode.isOwnUser + if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay + } + is TimelineItemPollContent -> TimestampPosition.Below + else -> TimestampPosition.Default } - is TimelineItemPollContent -> TimestampPosition.Below - else -> TimestampPosition.Default } + val paddingBehaviour = when (event.content) { is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index ed927956df0..777776ad908 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -60,9 +59,9 @@ fun TimelineItemGroupedEventsRow( eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, @@ -132,9 +131,9 @@ private fun TimelineItemGroupedEventsRowContent( eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 6a5cbfd9dd2..20444903adc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.designsystem.colors.gradientSubtleColors import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction @@ -77,9 +76,9 @@ internal fun TimelineItemRow( eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onContentClick = { onContentClick(event) }, onGalleryItemClick = { index -> onGalleryItemClick(event, index) }, onLongClick = { onLongClick(event) }, @@ -119,6 +118,7 @@ internal fun TimelineItemRow( onClick = { onContentClick(timelineItem) }, onReadReceiptsClick = onReadReceiptClick, onLongClick = { onLongClick(timelineItem) }, + timelineProtectionState = timelineProtectionState, eventSink = eventSink, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index a6015758fce..eb27134065e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -43,6 +45,7 @@ fun TimelineItemStateEventRow( onClick: () -> Unit, onLongClick: () -> Unit, onReadReceiptsClick: (event: TimelineItem.Event) -> Unit, + timelineProtectionState: TimelineProtectionState, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier ) { @@ -67,11 +70,11 @@ fun TimelineItemStateEventRow( .widthIn(max = 320.dp) ) { TimelineItemEventContentView( + eventId = event.eventId, content = event.content, onLinkClick = {}, onLinkLongClick = {}, - hideMediaContent = false, - onShowContentClick = {}, + timelineProtectionState = timelineProtectionState, eventSink = eventSink, onContentClick = null, onGalleryItemClick = {}, @@ -107,6 +110,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview { onClick = {}, onLongClick = {}, onReadReceiptsClick = {}, + timelineProtectionState = aTimelineProtectionState(), eventSink = {} ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt index 4aa41a472b1..8ed9358d0cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -12,11 +12,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -25,8 +29,13 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. @@ -41,18 +50,37 @@ fun TimelineItemAttachmentView( caption: String?, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { + val currentContentValidation by contentValidationState.collectOverallState() Column( modifier = modifier, ) { - TimelineItemAttachmentHeaderView( - icon = icon, - iconContentDescription = iconContentDescription, - filename = filename, - fileExtensionAndSize = fileExtensionAndSize, - hasCaption = caption != null, - onContentLayoutChange = onContentLayoutChange, - ) + if (currentContentValidation.isInvalid()) { + InvalidContentView( + // Add some spacing between the invalid content view and the caption + contentPadding = if (caption != null) PaddingValues(bottom = 8.dp) else PaddingValues.Zero, + onTextLayout = if (caption == null) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) + } else { + TimelineItemAttachmentHeaderView( + icon = icon, + iconContentDescription = iconContentDescription, + filename = filename, + fileExtensionAndSize = fileExtensionAndSize, + hasCaption = caption != null, + isLoading = !currentContentValidation.isValidated(), + onContentLayoutChange = onContentLayoutChange, + ) + } if (caption != null) { TimelineItemAttachmentCaptionView( modifier = Modifier.padding(top = 4.dp), @@ -70,6 +98,7 @@ private fun TimelineItemAttachmentHeaderView( filename: String, fileExtensionAndSize: String, hasCaption: Boolean, + isLoading: Boolean, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -86,12 +115,19 @@ private fun TimelineItemAttachmentHeaderView( .background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = iconContentDescription, - tint = ElementTheme.colors.iconPrimary, - modifier = Modifier.size(24.dp), - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = ElementTheme.colors.iconPrimary, + ) + } else { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.size(24.dp), + ) + } } Column { Text( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index e21da90e94d..bd6ab64fe7e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -9,10 +9,12 @@ package io.element.android.features.messages.impl.timeline.components.event import android.text.SpannedString import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -21,11 +23,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,14 +58,22 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.flow.flowOf +import kotlin.math.max @Composable fun TimelineItemAttachmentsListView( + eventId: EventId?, content: TimelineItemAttachmentsContent, onGalleryItemClick: (Int) -> Unit, onLinkClick: (Link) -> Unit, @@ -65,21 +81,74 @@ fun TimelineItemAttachmentsListView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { + val contentValidationState = rememberEventContentValidationState( + eventId = eventId, + needsValidation = content.isMedia, + ) + Column(modifier = modifier) { Column( modifier = Modifier.fillMaxWidth(), ) { + val validationStates = remember(content.attachments) { + List(content.attachments.size) { ContentValidationValue.Unknown }.toMutableStateList() + } content.attachments.forEachIndexed { index, attachment -> - Column { - if (index > 0) { + Column(modifier = Modifier.fillMaxWidth()) { + val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl) + val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl) + + val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { + if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) { + ContentValidationValue.Invalid + } else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) { + ContentValidationValue.Loading + } else { + mediaContentValidationState + } + } + + LaunchedEffect(itemContentValidationState) { + validationStates[index] = itemContentValidationState + } + + val needsSeparator = index in 1..max(1, content.attachments.lastIndex) + val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.isInvalid() == true + if (needsSeparator && !itemContentValidationState.isInvalid() && !isPreviousItemInvalid) { HorizontalDivider( color = ElementTheme.colors.borderInteractiveSecondary, ) } - AttachmentListItem( - attachment = attachment, - onClick = { onGalleryItemClick(index) }, - ) + + when (itemContentValidationState) { + ContentValidationValue.Invalid -> { + val shape = RoundedCornerShape(6.dp) + InvalidContentView( + modifier = Modifier + .border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape) + .clip(shape), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) + + if (index == content.attachments.lastIndex) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + } + ContentValidationValue.Loading -> { + CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally)) + } + ContentValidationValue.Valid -> { + AttachmentListItem( + attachment = attachment, + onClick = { onGalleryItemClick(index) }, + ) + } + else -> { + // TODO handle cases where either the content hasn't started validation or it failed to do so + } + } } } } @@ -213,6 +282,7 @@ internal fun TimelineItemAttachmentsListViewPreview( @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent ) = ElementPreview { TimelineItemAttachmentsListView( + eventId = EventId("\$eventId"), content = content, onGalleryItemClick = {}, onLinkClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d5a07194cf0..2f97c30e2de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons @@ -16,12 +17,15 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Audio(), @@ -31,6 +35,7 @@ fun TimelineItemAudioView( caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationState = contentValidationState, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 2951d13d656..3757919cc1f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -9,6 +9,10 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData @@ -33,24 +37,45 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemEventContentView( + eventId: EventId?, content: TimelineItemEventContent, - hideMediaContent: Boolean, onContentClick: (() -> Unit)?, onGalleryItemClick: ((Int) -> Unit), + timelineProtectionState: TimelineProtectionState, onLongClick: (() -> Unit)?, - onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { + val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) { + timelineProtectionState.hideMediaContent(eventId) + } + + val onShowContentClick = remember(timelineProtectionState.eventSink) { + { + timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId)) + } + } + + val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia) + + if (eventId != null) { + ValidateMediaHelper(eventId, content, contentValidationState, eventSink) + } + val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( @@ -82,18 +107,48 @@ fun TimelineItemEventContentView( modifier = modifier ) } - is TimelineItemImageContent -> TimelineItemImageView( - content = content, - hideMediaContent = hideMediaContent, - onContentClick = onContentClick, - onLongClick = onLongClick, - onShowContentClick = onShowContentClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier, - ) + is TimelineItemImageContent -> { + TimelineItemImageView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + contentValidationState = contentValidationState, + modifier = modifier, + ) + } + is TimelineItemStickerContent -> { + TimelineItemStickerView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowClick = onShowContentClick, + modifier = modifier, + contentValidationState = contentValidationState, + onContentLayoutChange = onContentLayoutChange, + ) + } + is TimelineItemVideoContent -> { + TimelineItemVideoView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + contentValidationState = contentValidationState, + modifier = modifier + ) + } is TimelineItemGalleryContent -> TimelineItemGalleryView( + eventId = eventId, content = content, onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLongClick = onLongClick, @@ -103,6 +158,7 @@ fun TimelineItemEventContentView( modifier = modifier, ) is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + eventId = eventId, content = content, onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLinkClick = onLinkClick, @@ -110,35 +166,22 @@ fun TimelineItemEventContentView( onContentLayoutChange = {}, modifier = modifier, ) - is TimelineItemStickerContent -> TimelineItemStickerView( - content = content, - hideMediaContent = hideMediaContent, - onContentClick = onContentClick, - onLongClick = onLongClick, - onShowClick = onShowContentClick, - modifier = modifier, - ) - is TimelineItemVideoContent -> TimelineItemVideoView( - content = content, - hideMediaContent = hideMediaContent, - onContentClick = onContentClick, - onLongClick = onLongClick, - onShowContentClick = onShowContentClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) - is TimelineItemFileContent -> TimelineItemFileView( - content = content, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) - is TimelineItemAudioContent -> TimelineItemAudioView( - content = content, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) + is TimelineItemFileContent -> { + TimelineItemFileView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + contentValidationState = contentValidationState, + ) + } + is TimelineItemAudioContent -> { + TimelineItemAudioView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + contentValidationState = contentValidationState, + ) + } is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) is TimelineItemStateContent -> TimelineItemStateView( content = content, @@ -155,9 +198,34 @@ fun TimelineItemEventContentView( state = presenter.present(), content = content, onContentLayoutChange = onContentLayoutChange, - modifier = modifier + modifier = modifier, + contentValidationState = contentValidationState, ) } is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") } } + +@Composable +private fun ValidateMediaHelper( + eventId: EventId, + content: TimelineItemEventContent, + contentValidationState: ContentValidationState, + eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, +) { + val mediaSources = when (content) { + is TimelineItemImageContent -> listOfNotNull(content.thumbnailSource, content.mediaSource) + is TimelineItemStickerContent -> listOfNotNull(content.thumbnailSource, content.mediaSource) + is TimelineItemVideoContent -> listOfNotNull(content.thumbnailSource, content.mediaSource) + is TimelineItemFileContent -> listOfNotNull(content.thumbnailSource, content.mediaSource) + is TimelineItemAudioContent -> listOf(content.mediaSource) + is TimelineItemVoiceContent -> listOf(content.mediaSource) + is TimelineItemGalleryContent -> content.items.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) } + is TimelineItemAttachmentsContent -> content.attachments.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) } + else -> return + } + val updatedEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(eventId, mediaSources) { + updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSources, contentValidationState)) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 7f8be6e60a8..5bae7fbe8c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -9,14 +9,25 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings /** @@ -27,6 +38,7 @@ fun TimelineItemFileView( content: TimelineItemFileContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Attachment(), @@ -36,6 +48,7 @@ fun TimelineItemFileView( caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationState = contentValidationState, ) } @@ -49,3 +62,43 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte ) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewScanningContentPreview() { + ElementTimelineItemPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemFileContent())) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewScanningContentFailedPreview() { + ElementTimelineItemPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemFileContent(caption = "A caption"))) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index c51055da929..9aaaa01e31b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,11 +54,16 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.bgSubtleTertiary import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.utils.time.formatShort import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.flowOf private const val MAX_TILES = 5 private val GALLERY_WIDTH = 264.dp @@ -67,6 +76,7 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp @Composable fun TimelineItemGalleryView( + eventId: EventId?, content: TimelineItemGalleryContent, onGalleryItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -87,21 +97,25 @@ fun TimelineItemGalleryView( when (totalItems) { 0 -> Unit 1 -> SingleItemLayout( + eventId = eventId, item = content.items[0], onClick = { onGalleryItemClick(0) }, onLongClick = onLongClick, ) 2 -> TwoItemLayout( + eventId = eventId, items = content.items, onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) 3 -> ThreeItemLayout( + eventId = eventId, items = content.items, onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) else -> FourPlusItemLayout( + eventId = eventId, items = content.items, showOverflow = showOverflow, overflowCount = overflowCount, @@ -139,11 +153,13 @@ fun TimelineItemGalleryView( @Composable private fun SingleItemLayout( + eventId: EventId?, item: GalleryItem, onClick: () -> Unit, onLongClick: (() -> Unit)?, ) { GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -157,6 +173,7 @@ private fun SingleItemLayout( @Composable private fun TwoItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -167,6 +184,7 @@ private fun TwoItemLayout( ) { items.forEachIndexed { index, item -> GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -182,6 +200,7 @@ private fun TwoItemLayout( @Composable private fun ThreeItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -191,6 +210,7 @@ private fun ThreeItemLayout( verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { GalleryItemCell( + eventId = eventId, item = items[0], isLast = false, remainingCount = 0, @@ -206,6 +226,7 @@ private fun ThreeItemLayout( ) { for (it in 1..2) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -222,6 +243,7 @@ private fun ThreeItemLayout( @Composable private fun FourPlusItemLayout( + eventId: EventId?, items: ImmutableList, showOverflow: Boolean, overflowCount: Int, @@ -238,6 +260,7 @@ private fun FourPlusItemLayout( ) { for (it in 0..1) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -260,6 +283,7 @@ private fun FourPlusItemLayout( if (itemIndex < items.size) { val isOverflowItem = showOverflow && i == bottomRowItems - 1 GalleryItemCell( + eventId = eventId, item = items[itemIndex], isLast = isOverflowItem, remainingCount = if (isOverflowItem) overflowCount else 0, @@ -277,6 +301,7 @@ private fun FourPlusItemLayout( @Composable private fun GalleryItemCell( + eventId: EventId?, item: GalleryItem, isLast: Boolean, remainingCount: Int, @@ -284,6 +309,20 @@ private fun GalleryItemCell( onLongClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { + val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true) + val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl) + val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl) + + val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { + if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) { + ContentValidationValue.Invalid + } else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) { + ContentValidationValue.Loading + } else { + mediaContentValidationState + } + } + Box( modifier = modifier .blurHashBackground(item.blurhash, alpha = 0.9f) @@ -305,7 +344,20 @@ private fun GalleryItemCell( VideoOverlay(duration = item.duration) } - if (isLast && remainingCount > 0) { + if (itemContentValidationState.isLoading()) { + CircularProgressIndicator() + } else if (itemContentValidationState.isInvalid()) { + Box( + modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle) + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + imageVector = CompoundIcons.Error(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + } else if (isLast && remainingCount > 0) { RemainingCountOverlay(count = remainingCount) } } @@ -374,6 +426,7 @@ internal fun TimelineItemGalleryViewPreview( @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, ) = ElementPreview { TimelineItemGalleryView( + eventId = null, content = content, onGalleryItemClick = {}, onLongClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 0e0a98a96cc..6773351b465 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -12,6 +12,7 @@ import android.text.SpannedString import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,6 +23,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,7 +38,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow @@ -48,10 +49,20 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -70,6 +81,7 @@ fun TimelineItemImageView( onShowContentClick: () -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val a11yLabel = stringResource(CommonStrings.common_image) val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel @@ -79,38 +91,69 @@ fun TimelineItemImageView( } else { Modifier } - TimelineItemAspectRatioBox( - modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowContentClick, + + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() + val isDangerous = eventContentValidation.isInvalid() + if (isContentBeingValidated || !isDangerous) { + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) - .then( - if (!isTalkbackActive() && onContentClick != null) { - Modifier - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick, - ) - .onKeyboardContextMenuAction(onLongClick) - } else { - Modifier - } - ), - model = content.thumbnailMediaRequestData, - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive() && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = content.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailMediaRequestData.source, + state = state, + onLoaded = { isLoaded = true }, + contentValidationState = contentValidationState, + ) + }, + ) + } + } } + } else { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = if (!content.showCaption) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) } if (content.showCaption) { @@ -163,6 +206,34 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon ) } +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemImageContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemImageContent(caption = "A caption")), + ) + } +} + @PreviewsDayNight @Composable internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index 104e420d51b..583840dcdd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -11,8 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,17 +28,32 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @@ -48,53 +66,83 @@ fun TimelineItemStickerView( onLongClick: (() -> Unit)?, onShowClick: () -> Unit, modifier: Modifier = Modifier, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) - Column( - modifier = modifier.semantics { contentDescription = description }, - ) { - TimelineItemAspectRatioBox( - modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - minHeight = STICKER_SIZE_IN_DP, - maxHeight = STICKER_SIZE_IN_DP, + + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() + val isDangerous = eventContentValidation.isInvalid() + if (isContentBeingValidated || !isDangerous) { + Column( + modifier = modifier.semantics { contentDescription = description }, ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowClick, + TimelineItemAspectRatioBox( + modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + minHeight = STICKER_SIZE_IN_DP, + maxHeight = STICKER_SIZE_IN_DP, ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxSize() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) - .then( - if (onContentClick != null) { - Modifier - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick, - onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ) - .onKeyboardContextMenuAction(onLongClick) - } else { - Modifier - } - ), - model = MediaRequestData( - source = content.preferredMediaSource, - kind = MediaRequestData.Kind.File( - fileName = content.filename, - mimeType = content.mimeType, + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowClick, + ) { + var isThumbnailLoaded by remember { mutableStateOf(false) } + + AsyncImage( + modifier = Modifier + .fillMaxSize() + .then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.preferredMediaSource, + kind = MediaRequestData.Kind.File( + fileName = content.filename, + mimeType = content.mimeType, + ), ), - ), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailSource ?: content.mediaSource, + state = state, + onLoaded = { isThumbnailLoaded = true }, + contentValidationState = contentValidationState, + ) + }, + ) + } + + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } } } + } else { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + ) } } @@ -109,3 +157,31 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke onShowClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemStickerContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewScanFailedContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemStickerContent()), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 0449ef26f0c..1a4be5d2e57 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis import io.element.android.libraries.androidutils.text.LinkifyHelper import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.wysiwyg.compose.EditorStyledText @@ -43,6 +44,9 @@ fun TimelineItemTextView( modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { + // The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode. + if (LocalUiTestMode.current) return + val emojiOnly = content.formattedBody.toString() == content.body && content.body.replace(" ", "").containsOnlyEmojis() val textStyle = when { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 8d1ef18f39f..d6e489dd010 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -14,16 +14,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,7 +44,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.timeline.aTimelineItemEvent @@ -54,14 +56,23 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -79,6 +90,7 @@ fun TimelineItemVideoView( onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val isTalkbackActive = isTalkbackActive() val a11yLabel = stringResource(CommonStrings.common_video) @@ -91,57 +103,88 @@ fun TimelineItemVideoView( } else { Modifier } - TimelineItemAspectRatioBox( - modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - contentAlignment = Alignment.Center, - ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowContentClick, - ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) - .then( - if (!isTalkbackActive && onContentClick != null) { - Modifier - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick, - ) - .onKeyboardContextMenuAction(onLongClick) - } else { - Modifier - } - ), - model = MediaRequestData( - source = content.thumbnailSource, - kind = MediaRequestData.Kind.Thumbnail( - width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, - height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, - ) - ), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() + val isDangerous = eventContentValidation.isInvalid() + if (isContentBeingValidated || !isDangerous) { + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + contentAlignment = Alignment.Center, + ) { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, ) { - Image( - imageVector = CompoundIcons.PlaySolid(), - contentDescription = stringResource(id = CommonStrings.a11y_play), - colorFilter = ColorFilter.tint(Color.White), - modifier = Modifier.semantics { hideFromAccessibility() } + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail( + width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, + height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, + ) + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailSource, + state = state, + onLoaded = { isLoaded = true }, + contentValidationState = contentValidationState, + ) + }, ) + + if (!isContentBeingValidated) { + Box( + modifier = Modifier.roundedBackground(), + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier.semantics { hideFromAccessibility() } + ) + } + } else { + CircularProgressIndicator() + } } } + } else { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = if (!content.showCaption) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) } if (content.showCaption) { @@ -232,3 +275,31 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { ) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVideoContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVideoContent(caption = "A caption")), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 86e3f1c8492..9f5df7dca48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -20,7 +20,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,9 +42,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview @@ -51,11 +59,20 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState import kotlinx.coroutines.delay @Composable @@ -64,81 +81,98 @@ fun TimelineItemVoiceView( content: TimelineItemVoiceContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { - fun playPause() { - state.eventSink(VoiceMessageEvent.PlayPause) - } - - val a11y = stringResource(CommonStrings.common_voice_message) - val a11yActionLabel = stringResource( - when (state.buttonType) { - VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play - VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause - VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading - VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry - VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown + val currentContentValidation by contentValidationState.collectOverallState() + if (currentContentValidation is ContentValidationValue.Invalid) { + InvalidContentView( + modifier = modifier, + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ), + ) + } else { + fun playPause() { + state.eventSink(VoiceMessageEvent.PlayPause) } - ) - Row( - modifier = modifier - .clearAndSetSemantics { - contentDescription = a11y - if (state.buttonType == VoiceMessageState.ButtonType.Disabled) { - disabled() - } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) { - onClick(label = a11yActionLabel) { - playPause() - true + + val a11y = stringResource(CommonStrings.common_voice_message) + val a11yActionLabel = stringResource( + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play + VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause + VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading + VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry + VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown + } + ) + Row( + modifier = modifier + .clearAndSetSemantics { + contentDescription = a11y + if (state.buttonType == VoiceMessageState.ButtonType.Disabled) { + disabled() + } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) { + onClick(label = a11yActionLabel) { + playPause() + true + } } } - } - .onSizeChanged { - onContentLayoutChange( - ContentAvoidingLayoutData( - contentWidth = it.width, - contentHeight = it.height, + .onSizeChanged { + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = it.width, + contentHeight = it.height, + ) ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isTalkbackActive()) { + if (currentContentValidation is ContentValidationValue.Valid) { + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Downloading -> ProgressButton() + VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) + } + } else { + ProgressButton(displayImmediately = true) + } + } + Spacer(Modifier.width(8.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, + ) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - if (!isTalkbackActive()) { - when (state.buttonType) { - VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Downloading -> ProgressButton() - VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } - } - Spacer(Modifier.width(8.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - PlaybackSpeedButton( - speed = state.playbackSpeed, - onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, - ) - Text( - text = state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Spacer(Modifier.width(8.dp)) + WaveformPlaybackView( + showCursor = state.showCursor, + playbackProgress = state.progress, + waveform = content.waveform, + modifier = Modifier + .weight(1f) + .height(34.dp), + seekEnabled = !isTalkbackActive(), + onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, ) } - Spacer(Modifier.width(8.dp)) - WaveformPlaybackView( - showCursor = state.showCursor, - playbackProgress = state.progress, - waveform = content.waveform, - modifier = Modifier - .weight(1f) - .height(34.dp), - seekEnabled = !isTalkbackActive(), - onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, - ) } } @@ -309,3 +343,33 @@ internal fun ProgressButtonPreview() = ElementPreview { ProgressButton(displayImmediately = false) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview { + TimelineItemVoiceView( + state = aVoiceMessageState(), + content = aTimelineItemVoiceContent(), + onContentLayoutChange = {}, + contentValidationState = remember { DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)) } + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache, + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVoiceContent())) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt index a299d2f1ced..cebe7f93b27 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.layout import android.text.Layout +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.wysiwyg.compose.EditorStyledText import kotlin.math.max import kotlin.math.min @@ -41,6 +43,7 @@ import kotlin.math.roundToInt * @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`. * @param content The 'content' component of the layout. */ +@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't @Composable fun ContentAvoidingLayout( overlay: @Composable () -> Unit, @@ -52,6 +55,15 @@ fun ContentAvoidingLayout( ) { val scope = remember { ContentAvoidingLayoutScopeInstance() } + // Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode. + if (LocalUiTestMode.current) { + Column { + scope.content() + overlay() + } + return + } + Layout( modifier = modifier, content = { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 5aa99fd4eb9..b3d90faba47 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -15,6 +15,9 @@ import kotlin.time.Duration @Immutable sealed interface TimelineItemEventContent { val type: String + + val isMedia: Boolean + get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent } interface TimelineItemEventMutableContent { @@ -123,3 +126,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? { else -> null } } + +fun TimelineItemEventContentWithAttachment.blurHash(): String? { + return when (this) { + is TimelineItemImageContent -> blurhash + is TimelineItemVideoContent -> blurHash + is TimelineItemStickerContent -> blurhash + else -> null + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index 0fadb6c9a60..62511fccc5f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -27,10 +27,11 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider, + val validationState: ContentValidationState, + ) : TimelineProtectionEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index b2ea1d6bd85..dc70b63f328 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -12,23 +12,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.room.BaseRoom import kotlinx.collections.immutable.toImmutableSet +@SingleIn(RoomScope::class) @Inject class TimelineProtectionPresenter( private val mediaPreviewService: MediaPreviewService, private val room: BaseRoom, + private val contentScannerService: ContentScannerService, ) : Presenter { - private val allowedEvents = mutableStateOf>(setOf()) + private val allowedEvents = mutableStateSetOf() @Composable override fun present(): TimelineProtectionState { @@ -36,13 +41,14 @@ class TimelineProtectionPresenter( mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue } }.collectAsState() val roomInfo = room.roomInfoFlow.collectAsState() + val protectionState by remember { derivedStateOf { val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule) if (isPreviewEnabled) { ProtectionState.RenderAll } else { - ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet()) + ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) } } } @@ -50,7 +56,10 @@ class TimelineProtectionPresenter( fun handleEvent(event: TimelineProtectionEvent) { when (event) { is TimelineProtectionEvent.ShowContent -> { - allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId) + allowedEvents += setOfNotNull(event.eventId) + } + is TimelineProtectionEvent.ValidateContent -> { + contentScannerService.scan(event.eventId, event.mediaSources, event.validationState) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt new file mode 100644 index 00000000000..6dd864815f4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.util + +import coil3.compose.AsyncImagePainter +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import okio.IOException + +fun handleAsyncImageStateChange( + mediaSource: MediaSource?, + state: AsyncImagePainter.State, + onLoaded: () -> Unit, + contentValidationState: ContentValidationState?, +) { + if (state is AsyncImagePainter.State.Success) { + onLoaded() + } + + val safeUrl = mediaSource?.safeUrl ?: return + val updatedContentValidationValue = when (state) { + is AsyncImagePainter.State.Empty -> ContentValidationValue.Unknown + is AsyncImagePainter.State.Loading -> ContentValidationValue.Loading + is AsyncImagePainter.State.Success -> ContentValidationValue.Valid + is AsyncImagePainter.State.Error -> { + when (val cause = state.result.throwable) { + is ClientException.ContentScanner if cause.reason.isDangerous() -> ContentValidationValue.Invalid + is IOException -> { + // Use `Unknown` instead of `Invalid` to allow retrying the download later + ContentValidationValue.Unknown + } + else -> ContentValidationValue.UnrecoverableError(cause) + } + } + } + + contentValidationState?.update(safeUrl, updatedContentValidationValue) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index 61b1fc8d261..0ac8c3be1f0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig @@ -315,6 +316,8 @@ class MessageComposerPresenterSlashCommandTest { notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, featureFlagService = featureFlagService, + contentScannerService = { _, _, _ -> }, + contentValidationCache = InMemoryEventContentValidationCache(), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 499e4bf91a3..c164a301601 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider @@ -1649,6 +1650,8 @@ class MessageComposerPresenterTest : RobolectricTest() { notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, featureFlagService = featureFlagService, + contentScannerService = { _, _, _ -> }, + contentValidationCache = InMemoryEventContentValidationCache(), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index f8f23b35f10..59ad98fbeb4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -19,6 +19,8 @@ import io.element.android.features.messages.impl.timeline.components.MessageShie import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -28,6 +30,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId @@ -1562,6 +1565,7 @@ class TimelinePresenterTest { featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, + timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1582,6 +1586,7 @@ class TimelinePresenterTest { analyticsService = FakeAnalyticsService(), liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, + timelineProtectionPresenter = timelineProtectionPresenter, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index dc63817e99e..6348d9b1155 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState @@ -67,7 +67,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), eventSink = eventsRecorder, ), ) @@ -79,7 +79,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = true, eventSink = eventsRecorder, ), @@ -98,7 +98,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = false, eventSink = eventsRecorder, ), @@ -133,7 +133,7 @@ class TimelineViewTest : RobolectricTest() { timelineItems = persistentListOf( aTimelineItemEvent( // Do not use a Text because EditorStyledText cannot be used in UI test. - content = aTimelineItemImageContent(), + content = aTimelineItemTextContent(), messageShield = MessageShield.UnverifiedIdentity(true), ), ), @@ -155,7 +155,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = false, eventSink = eventsRecorder, messageShield = aCriticalShield(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index d6af8ebd680..11093bd74f9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -9,19 +9,28 @@ package io.element.android.features.messages.impl.timeline.protection import com.google.common.truth.Truth.assertThat +import io.element.android.features.contentscanner.api.ContentScannerService +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaPreviewConfig import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -87,11 +96,49 @@ class TimelineProtectionPresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - validate media scans the media source`() = runTest { + val url = "https://example.com/media" + + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite), roomCoroutineScope = backgroundScope) + val contentScannerService = lambdaRecorder { _: EventId, _: List, state: ContentValidationState -> + state.update(url, ContentValidationValue.Valid) + } + val presenter = createPresenter( + mediaPreviewService = mediaPreviewService, + room = room, + contentScannerService = contentScannerService, + ) + presenter.test { + val validationState = DefaultContentValidationState(initial = mapOf(url to ContentValidationValue.Unknown)) + assertThat(validationState.getCurrentOverallState().isValid()).isFalse() + + val initialState = awaitItem() + initialState.eventSink( + TimelineProtectionEvent.ValidateContent( + eventId = AN_EVENT_ID, + mediaSources = listOf(MediaSource(url)), + validationState = validationState + ) + ) + + runCurrent() + + contentScannerService.assertions().isCalledOnce() + assertThat(validationState.getCurrentOverallState().isValid()).isTrue() + } + } + private fun createPresenter( room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), + contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: List, _: ContentValidationState -> }, ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, + contentScannerService = contentScannerService, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index f323b316f7b..b1501601d9c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -124,3 +124,34 @@ data class CallNotifyContent( ) : EventContent data object UnknownContent : EventContent + +fun EventContent.isMediaContent(): Boolean { + return when (this) { + is MessageContent -> type is MessageTypeWithAttachment || type is GalleryMessageType + is StickerContent -> true + else -> false + } +} + +fun EventContent.mediaSources(): List { + return when (this) { + is MessageContent -> mediaSources() + is StickerContent -> listOfNotNull(source, info.thumbnailSource) + else -> emptyList() + } +} + +fun MessageContent.mediaSources(): List { + return when (val messageType = type) { + is MessageTypeWithAttachment -> when (messageType) { + is ImageMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + is VideoMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + is AudioMessageType -> listOf(messageType.source) + is VoiceMessageType -> listOf(messageType.source) + is FileMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + else -> emptyList() + } + is GalleryMessageType -> messageType.items.flatMap { it.mediaSources() } + else -> emptyList() + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index fdc538ec44a..2f61b8f58a1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -115,6 +115,14 @@ sealed interface GalleryItemType { data class Video(val content: VideoMessageType) : GalleryItemType data class File(val content: FileMessageType) : GalleryItemType data class Other(val itemType: String, val body: String) : GalleryItemType + + fun mediaSources(): List = when (this) { + is Image -> listOfNotNull(content.source, content.info?.thumbnailSource) + is Audio -> listOf(content.source) + is Video -> listOfNotNull(content.source, content.info?.thumbnailSource) + is File -> listOfNotNull(content.source, content.info?.thumbnailSource) + is Other -> emptyList() + } } data class OtherMessageType( diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 713057ecda7..d31b5671eb2 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -40,7 +40,7 @@ internal class CoilMediaFetcher( onSuccess = { it }, onFailure = { error -> if (error.isNetworkError()) { - null + throw error } else { fetchContent(mediaSource) } @@ -73,7 +73,7 @@ internal class CoilMediaFetcher( .onFailure { Timber.e(it) } - .getOrNull() + .getOrThrow() } private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? { @@ -83,7 +83,7 @@ internal class CoilMediaFetcher( byteArray.asSourceResult() }.onFailure { Timber.e(it) - }.getOrNull() + }.getOrThrow() } private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt new file mode 100644 index 00000000000..94d2512f510 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest + +/** + * Wrapper for the content validation state of an event. + */ +@Immutable +interface ContentValidationState { + val overallStateFlow: Flow + fun getMediaStateFlow(url: String): Flow + fun getCurrentOverallState(): ContentValidationValue + fun getCurrentMediaState(url: String): ContentValidationValue + fun update(url: String, newValue: ContentValidationValue) +} + +@Immutable +class NoopContentValidationState : ContentValidationState { + override val overallStateFlow: Flow = MutableStateFlow(ContentValidationValue.Valid) + override fun getMediaStateFlow(url: String): Flow = MutableStateFlow(ContentValidationValue.Valid) + override fun getCurrentOverallState(): ContentValidationValue = ContentValidationValue.Valid + override fun getCurrentMediaState(url: String): ContentValidationValue = ContentValidationValue.Valid + override fun update(url: String, newValue: ContentValidationValue) {} +} + +@Immutable +class DefaultContentValidationState( + private val states: MutableStateFlow>, +) : ContentValidationState { + @OptIn(ExperimentalCoroutinesApi::class) + override val overallStateFlow: Flow = states + .mapLatest { states -> calculateOverallState(states) } + .distinctUntilChanged() + + constructor() : this(states = MutableStateFlow(emptyMap())) + + constructor(initial: Map) : this(states = MutableStateFlow(initial)) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getMediaStateFlow(url: String): Flow { + return states.mapLatest { states -> + states[url] ?: ContentValidationValue.Unknown + }.distinctUntilChanged() + } + + override fun getCurrentOverallState(): ContentValidationValue { + return calculateOverallState(states.value) + } + + override fun getCurrentMediaState(url: String): ContentValidationValue { + return states.value[url] ?: ContentValidationValue.Unknown + } + + /** + * Updates the validation state with a new value. + */ + override fun update(url: String, newValue: ContentValidationValue) { + val current = states.value[url] + if (current != null && current.isValidated() && newValue.isLoading()) { + return + } + + val updated = states.value.toMutableMap() + updated[url] = newValue + states.value = updated + } +} + +private fun calculateOverallState(states: Map): ContentValidationValue { + return when { + states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid + states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading + states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid + else -> ContentValidationValue.Unknown + } +} + +@Immutable +sealed interface ContentValidationValue { + data object Valid : ContentValidationValue + data object Invalid : ContentValidationValue + data object Loading : ContentValidationValue + data class UnrecoverableError(val error: Throwable) : ContentValidationValue + data object Unknown : ContentValidationValue + + fun isValidated(): Boolean = when (this) { + is Valid -> true + is Invalid -> true + is Loading -> false + is UnrecoverableError -> true + is Unknown -> false + } + + fun isValid(): Boolean = this is Valid + fun isInvalid(): Boolean = this is Invalid + fun isLoading(): Boolean = this is Loading +} + +@Composable +fun ContentValidationState.collectOverallState(): State { + return produceState(getCurrentOverallState()) { + overallStateFlow.collect { value = it } + } +} + +@Composable +fun ContentValidationState.collectMediaState(url: String?): State { + if (url == null) { + return remember { mutableStateOf(ContentValidationValue.Unknown) } + } + + return produceState(getCurrentMediaState(url)) { + getMediaStateFlow(url).collect { value = it } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt new file mode 100644 index 00000000000..7fa48ad9331 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import io.element.android.libraries.matrix.api.core.EventId + +/** + * A cache that holds the validation state of event contents, based on their [EventId]. + */ +interface EventContentValidationCache { + operator fun get(eventId: EventId): ContentValidationState +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt new file mode 100644 index 00000000000..12bde5abb79 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import io.element.android.libraries.matrix.api.core.EventId + +class InMemoryEventContentValidationCache( + initial: Map = emptyMap(), +) : EventContentValidationCache { + private val cache = initial.toMutableMap() + + override operator fun get(eventId: EventId): ContentValidationState { + return cache[eventId] ?: DefaultContentValidationState() + } + + fun put(eventId: EventId, state: ContentValidationState) { + cache[eventId] = state + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt new file mode 100644 index 00000000000..263e08c1524 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * A view to display when the media content is invalid or dangerous. + */ +@Composable +fun InvalidContentView( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues.Zero, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + val updatedOnTextLayout by rememberUpdatedState(onTextLayout) + Row( + modifier = modifier + .background(color = ElementTheme.colors.bgCriticalSubtle) + .padding(contentPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = CompoundIcons.Error(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary + ) + Column(Modifier.weight(1f)) { + Text( + text = stringResource(CommonStrings.content_scanner_unsafe_title), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + val text = stringResource(CommonStrings.content_scanner_unsafe_message) + val textMeasurer = rememberTextMeasurer() + val textContent = remember(text) { + movableContentOf( + @Composable { + Text( + text = text, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + ) + } + + // BoxWithConstraints is needed to be able to calculate the text layout in the current constraints, so we can pass it to the onTextLayout callback. + // However, this can't be used inside a SubComposeLayout (like the one in the text composer), so we only use it when the onTextLayout callback + // is provided. + if (updatedOnTextLayout != null) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + LaunchedEffect(text) { + updatedOnTextLayout?.invoke(textMeasurer.measure(text, overflow = TextOverflow.Visible, constraints = constraints)) + } + + textContent() + } + } else { + textContent() + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun InvalidContentViewPreview() = ElementPreview { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + InvalidContentView() + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt new file mode 100644 index 00000000000..ab7f6da0426 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent + +private val noopValue = NoopContentValidationState() + +/** + * A composition local that provides an [EventContentValidationCache] instance, so we can check if a given event content has been validated or not. + */ +val LocalEventContentValidationState = staticCompositionLocalOf { NoopEventContentValidationCache() } + +/** + * A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS. + */ +class NoopEventContentValidationCache : EventContentValidationCache { + override operator fun get(eventId: EventId): ContentValidationState = noopValue +} + +/** + * A helper to remember the validation state of an event content, based on its [EventId] and [EventContent], if known. + */ +@Composable +fun rememberEventContentValidationState(eventId: EventId?, eventContent: EventContent?): ContentValidationState { + val needsValidation = remember(eventContent) { + when (eventContent) { + is MessageContent -> eventContent.isMediaContent() + is StickerContent -> true + // If the event content is not known or not a media content, we don't need to validate it. + else -> false + } + } + + return rememberEventContentValidationState(eventId, needsValidation) +} + +/** + * A helper to remember the validation state of an event content, based on its [EventId] and whether it needs validation or not. + */ +@Composable +fun rememberEventContentValidationState(eventId: EventId?, needsValidation: Boolean): ContentValidationState { + if (!needsValidation) { + return noopValue + } + + return rememberEventContentValidationStateInternal(eventId) +} + +@Composable +private fun rememberEventContentValidationStateInternal(eventId: EventId?): ContentValidationState { + val cache = LocalEventContentValidationState.current + return remember(eventId) { + if (eventId != null) { + cache[eventId] + } else { + noopValue + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt index 3179c748ea5..2105655b01a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt @@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) { is InReplyToDetails.Error -> eventId } +fun InReplyToDetails.content() = when (this) { + is InReplyToDetails.Ready -> eventContent + is InReplyToDetails.Loading -> null + is InReplyToDetails.Error -> null +} + fun InReplyTo.map( permalinkParser: PermalinkParser, ) = when (this) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 4d0e02aa6f0..bcc3fe47a7d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,6 +45,8 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.ui.strings.CommonStrings @@ -55,18 +58,21 @@ import io.element.android.libraries.ui.strings.CommonStrings fun InReplyToView( inReplyTo: InReplyToDetails, hideImage: Boolean, + contentValidationValue: ContentValidationValue, modifier: Modifier = Modifier, maxLines: Int = 2, ) { when (inReplyTo) { - is InReplyToDetails.Ready -> { - ReplyToReadyContent( + is InReplyToDetails.Ready -> when (contentValidationValue) { + ContentValidationValue.Valid -> ReplyToReadyContent( senderId = inReplyTo.senderId, senderProfile = inReplyTo.senderProfile, metadata = inReplyTo.metadata(hideImage), maxLines = maxLines, modifier = modifier, ) + ContentValidationValue.Invalid -> ReplyToInvalidContent() + else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) @@ -86,7 +92,7 @@ private fun ReplyToReadyContent( val paddings = if (metadata is InReplyToMetadata.Thumbnail) { PaddingValues(end = 8.dp) } else { - PaddingValues(start = 8.dp, end = 8.dp) + PaddingValues(horizontal = 8.dp) } Row( modifier @@ -213,11 +219,31 @@ private fun ReplyToContentText( } } +@Composable +private fun ReplyToInvalidContent( + modifier: Modifier = Modifier +) { + InvalidContentView( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp), + onTextLayout = null, + ) +} + @PreviewsDayNight @Composable internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { InReplyToView( inReplyTo = inReplyTo, hideImage = false, + contentValidationValue = ContentValidationValue.Valid, ) } + +@PreviewsDayNight +@Composable +internal fun ReplyToInvalidContentPreview() { + ElementPreview { + ReplyToInvalidContent(modifier = Modifier.padding(10.dp)) + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt index 3a2f2321db5..fb60a7488c9 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -18,6 +18,7 @@ data class GalleryItemData( val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val type: Type, + val blurHash: String?, ) : Parcelable { enum class Type { Image, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index f6de6cf8399..7b9f65e60e9 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Params data class EventGallery( @@ -55,6 +56,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val avatarInfo: AvatarInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Params } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 10858c76c86..4cbcd75a5ef 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint { ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, + // Maybe add avatar blurhash if available? + blurHash = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 05d5dd40229..f489186ade1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -116,6 +116,7 @@ class EventItemFactory( ), mediaSource = c.source, thumbnailSource = c.info?.thumbnailSource, + blurHash = c.info?.blurhash, ) } is GalleryItemType.Video -> { @@ -136,6 +137,7 @@ class EventItemFactory( ), mediaSource = c.source, thumbnailSource = c.info?.thumbnailSource, + blurHash = c.info?.blurhash, ) } is GalleryItemType.Audio -> { @@ -242,6 +244,7 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) is StickerMessageType -> listOf(MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -264,6 +267,7 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) is VideoMessageType -> listOf(MediaItem.Video( id = currentTimelineItem.uniqueId, @@ -286,6 +290,7 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) is VoiceMessageType -> listOf(MediaItem.Voice( id = currentTimelineItem.uniqueId, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt index 611d619d520..957f0011d3f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.blurHash import io.element.android.libraries.mediaviewer.impl.model.eventId import io.element.android.libraries.mediaviewer.impl.model.mediaInfo import io.element.android.libraries.mediaviewer.impl.model.mediaSource @@ -68,6 +69,7 @@ class MediaGalleryFlowNode( val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : NavTarget } @@ -104,6 +106,7 @@ class MediaGalleryFlowNode( mediaInfo = item.mediaInfo(), mediaSource = item.mediaSource(), thumbnailSource = item.thumbnailSource(), + blurHash = item.blurHash(), ) ) } @@ -134,6 +137,7 @@ class MediaGalleryFlowNode( mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, + blurHash = navTarget.blurHash, ), callback = callback, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt index 7d9e6346879..3aad3f10612 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -34,6 +34,7 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -51,6 +52,7 @@ fun VideoItemView( ) { Box( modifier = modifier + .blurHashBackground(blurHash = video.blurHash) .aspectRatio(1f) .combinedClickable( onClick = onClick, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt index 90708db802a..5a0b56a9b7e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -35,6 +35,7 @@ sealed interface MediaItem { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Event { val thumbnailMediaRequestData: MediaRequestData get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) @@ -46,6 +47,7 @@ sealed interface MediaItem { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Event { val thumbnailMediaRequestData: MediaRequestData get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) @@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? { is MediaItem.Voice -> null } } + +fun MediaItem.Event.blurHash(): String? { + return when (this) { + is MediaItem.Image -> blurHash + is MediaItem.Video -> blurHash + else -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt index be737878284..96059cfd1f2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt @@ -34,6 +34,7 @@ fun aMediaItemImage( ), mediaSource = MediaSource(mediaSourceUrl), thumbnailSource = null, + blurHash = null, ) } @@ -50,6 +51,7 @@ fun aMediaItemVideo( ), mediaSource = mediaSource, thumbnailSource = null, + blurHash = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index 2dfb11a89f7..d254985ebfc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource( mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, + blurHash = galleryItem.blurHash, ) GalleryItemData.Type.Audio -> MediaItem.Audio( id = id, @@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource( mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, + blurHash = galleryItem.blurHash, ) } mixedItems.add(mediaItem) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt index 6809f6b6856..f347e8181f9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt @@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource( ), mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = params.blurHash, ) ), fileItems = persistentListOf(), @@ -79,6 +80,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when { mediaInfo = mediaInfo, mediaSource = mediaSource, thumbnailSource = thumbnailSource, + blurHash = blurHash, ) } mediaInfo.mimeType.isMimeTypeVideo() -> { @@ -88,6 +90,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when { mediaInfo = mediaInfo, mediaSource = mediaSource, thumbnailSource = thumbnailSource, + blurHash = blurHash, ) } mediaInfo.mimeType.isMimeTypeAudio() -> { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 9cd134b843a..0c2e36903c3 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest { avatarInfo = AvatarInfo(filename = "avatar.png"), mediaSource = MediaSource(url = "avatarUrl"), thumbnailSource = null, + blurHash = null, ) ) assertThat(result.plugins).contains(callback) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index 7d0018f1207..7147e74e723 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt @@ -226,6 +226,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource(""), thumbnailSource = null, + blurHash = null, ) ) } @@ -326,6 +327,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource(""), thumbnailSource = null, + blurHash = null, ) ) } @@ -437,6 +439,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -498,6 +501,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -744,6 +748,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource(""), thumbnailSource = null, + blurHash = null, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt index 88aefb842cb..a5151f3b68a 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt @@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest { ), mediaSource = MediaSource("url"), thumbnailSource = MediaSource("url_thumbnail"), + blurHash = A_BLUR_HASH, ) ), fileItems = persistentListOf() diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt index 710aaff1abf..670dfd62428 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt @@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), type = GalleryItemData.Type.Image, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest { mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg), mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), type = GalleryItemData.Type.Video, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest { mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4), mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("audio_url"), thumbnailSource = null, type = GalleryItemData.Type.Audio, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("file_url"), thumbnailSource = null, type = GalleryItemData.Type.File, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = null, type = GalleryItemData.Type.Image, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = null, type = GalleryItemData.Type.Image, + blurHash = null, ), GalleryItemData( filename = "document.pdf", @@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("file_url"), thumbnailSource = null, type = GalleryItemData.Type.File, + blurHash = null, ), GalleryItemData( filename = "video.mp4", @@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("video_url"), thumbnailSource = null, type = GalleryItemData.Type.Video, + blurHash = null, ), ), galleryInfo = aGalleryInfo(), diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index f917e8e0d1c..b25b8577ee9 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -114,6 +114,7 @@ class MediaViewerPresenterTest { avatarInfo = AvatarInfo(filename = "avatar.png"), mediaSource = aMediaSource(), thumbnailSource = null, + blurHash = null, ), room = FakeJoinedRoom( baseRoom = FakeBaseRoom( @@ -983,4 +984,5 @@ internal fun createMediaViewerEntryPointParams( mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, + blurHash = null, ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt index a8696909079..ad44d1b372f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt @@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = params.mediaInfo, mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = null, ) } ) @@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = params.mediaInfo, mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = null, ) } ) @@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = mediaInfo, mediaSource = aMediaSource(url = "aUrl"), thumbnailSource = aMediaSource(url = "aThumbnailUrl"), + blurHash = null, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt index 039f5165475..a55e725c047 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -20,6 +21,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,8 +38,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView +import io.element.android.libraries.matrix.ui.messages.reply.eventId import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.ui.strings.CommonStrings @@ -134,28 +140,44 @@ private fun ReplyToModeView( onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + val shape = RoundedCornerShape(6.dp) + val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent) + val currentValidationState by contentValidationState.collectOverallState() + + val isContentInvalid = currentValidationState.isInvalid() + + Box( modifier - .clip(RoundedCornerShape(6.dp)) - .background(ElementTheme.colors.bgCanvasDefault) - .border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) + .clip(shape) + .background( + color = if (isContentInvalid) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault, + shape = shape, + ) + .border( + width = 1.dp, + color = if (isContentInvalid) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary, + shape = shape, + ) .padding(4.dp) ) { // Larger density DPI and font scale means less space to display the content, so we limit it to 1 line to avoid overflow issues val currentDensity = LocalDensity.current val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE val maxReplyContentLines = if (hasLowResolution) 1 else 2 + InReplyToView( inReplyTo = replyToDetails, hideImage = hideImage, + contentValidationValue = currentValidationState, maxLines = maxReplyContentLines, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close), - tint = ElementTheme.colors.iconSecondary, + tint = if (isContentInvalid) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary, modifier = Modifier + .align(Alignment.TopEnd) .padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp) .size(16.dp) .clickable( diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 82acfd14b35..d23f2acb593 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -434,6 +434,10 @@ Reason: %1$s." "Waiting for live location…" "Anyone can see history" "You" + "Download not available." + "File not found" + "Preview and download have been disabled." + "The file is not safe" "%1$s (%2$s) shared this message since you were not in the room when it was sent." "%1$s shared this message since you were not in the room when it was sent." "This room has been configured so that new members can read history. %1$s" diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index fdb4e27ef06..060360c7d44 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -155,10 +155,20 @@ class KonsistPreviewTest { "TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithThreadSummaryPreview", + "TimelineItemFileViewScanningContentFailedPreview", + "TimelineItemFileViewScanningContentPreview", "TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemImageViewHideMediaContentPreview", + "TimelineItemImageViewScanningContentFailedPreview", + "TimelineItemImageViewScanningContentPreview", + "TimelineItemStickerViewScanFailedContentPreview", + "TimelineItemStickerViewScanningContentPreview", "TimelineItemVideoViewHideMediaContentPreview", + "TimelineItemVideoViewScanningContentFailedPreview", + "TimelineItemVideoViewScanningContentPreview", + "TimelineItemVoiceViewScanningContentFailedPreview", + "TimelineItemVoiceViewScanningContentPreview", "TimelineItemVoiceViewUnifiedPreview", "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", diff --git a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt index e9bd34d10ef..971783cd630 100644 --- a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt +++ b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt @@ -26,6 +26,8 @@ import com.android.resources.Density.DEFAULT_DENSITY import com.android.resources.NightMode import com.android.resources.ScreenOrientation import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview import java.util.Locale @@ -55,6 +57,7 @@ object ScreenshotTest { setLocales(LocaleList(locale)) uiMode = preview.previewInfo.uiMode }, + LocalEventContentValidationState provides NoopEventContentValidationCache(), ) { ElementTheme { Box( diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..59c0bd6a553 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e86072c5d0feefeade772c475516c15745158263c25d1b1bc3508f67a9dc193 +size 24368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..21bba287aba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e16d3c966e886c87587daad3328a0c40706861c17da1e3d2991f8197e4e3057 +size 24544 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..ddbd585aa42 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:659b376f3030142fe61bd95e62c312b2272179fbed3d5fe07d9446c8d5b07594 +size 15423 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..3584a586310 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a352a0d80264a2998ffe5c08665e752ef34b96a92430eb047badd786c0a8b7be +size 15754 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..62a7f9dc0c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee577c2edd4519bf435e39eded760f9b1dd19bf07e16057741b964f4d6453232 +size 23882 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..fc4f17b2198 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb080f17bcecb43ec808bae94be4e0faaf0668e9d448e3588d8d11f1b88de82 +size 24090 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..930a0ecf824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9888d8f9f8b3ded76f601c7300c33c8d111aa926ed7bc0793fe3212710786656 +size 119693 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..e52ec14d2e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c90a542bf2c396ec844e1e8b1763a94078f0139cbfdd1cd8ec7ab7e4e21f2b91 +size 120861 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png new file mode 100644 index 00000000000..d28cd04dc0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5961fcb0f13d3e17be829d8beb4de22cebf90cc065dde6fa6e2f817ea1ca3827 +size 21568 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png new file mode 100644 index 00000000000..998a8437089 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7c8cf238a5953cfb723c3f83d1c3bd8efd74a5472494abbae9e5afb2375abc7 +size 21672 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..318b30a06e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebb700f635d3315fa4201abdcb74d65cccbca13e827c39ae86211435a2950d5f +size 55865 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..52a471ddc4f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e98e96ceacb199ccc3922cd332032490ebd6074c9d7b94a64b095fe3b9d7cce +size 55793 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..62a7f9dc0c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee577c2edd4519bf435e39eded760f9b1dd19bf07e16057741b964f4d6453232 +size 23882 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..fc4f17b2198 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb080f17bcecb43ec808bae94be4e0faaf0668e9d448e3588d8d11f1b88de82 +size 24090 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..b683d835823 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:710c36047d9e1ca7c3d242cd53ac9232c536c36b3706ae22f49ab62e05e57a23 +size 284681 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..34d69603dc7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:380a80cb0cfdd1c0ed2bb90793e640552323c9719b1914e10ca6be0abc8c9929 +size 283849 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..020e88d3753 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f45479b43f690785e764547c9370ca73320210200e78c0873f7d0bdae299a41c +size 21781 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..9893d3cadff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:564bc123926c3c685310269c63409bc69d6423bd32f2ad8c222b73bed32d5f36 +size 21933 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..09ecd0f3df5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:691b064d8dcdbc3197e9c8ed51d035aba438bb2d86853658ae22b1e7d5574a60 +size 5991 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..81abea7f34a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a28425f9079a5d27a05744808b599a2b21b78f501bd84909d00729f7bb55528b +size 6030 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png new file mode 100644 index 00000000000..d0356842774 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e99e5407e785bec42ab92c966ca2e76810f3263968618ca7a0f7438d3ca8b7b3 +size 14144 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png new file mode 100644 index 00000000000..492d3d25f90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee69ac6b6c9b0f436efadb8a843d553b434622b911296e2148427e392c975ce3 +size 13574 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png new file mode 100644 index 00000000000..980acf80096 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb9565278a98b2b785124ff810c6b8fec733883edf94a1f5cce51d3385e3fa5c +size 14163 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png new file mode 100644 index 00000000000..a24b5c05a96 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10aa3fecd1ddbdfd45c76dd25629485a8106ef0a67bc97e2f545602af7a957fd +size 13525 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png index 4caec798578..e5602cc9133 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5aa13d38c3c677dc552e1ad6ffdc19caa37c96287945b877804584e418e4acbc -size 10915 +oid sha256:c9e491bd12948a77b277e16594ce6aea80e7eddf0e2a2efb27e87546f156525b +size 10909 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png index 6482d7322b0..bdf129ee4aa 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:416b0ee79cd8c07fed42739309059895f8c36c20d3838be4a5a4afbfbd0b5394 -size 18548 +oid sha256:63f992f8496bac188c746dfc000f3d62d9f88c08131110d5977159b72d61169a +size 19669 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png index 6f42ac691eb..679685e9364 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c82fdba61809fce17e2eb7e9a79857a260e5598a2a9db88f481db69e2c7f79fa -size 7745 +oid sha256:ee086e521812765d474d094157c0311f48e2a3feff3bf6756be06974114c020f +size 7734 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png index fddd092e487..e26ea57b895 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36aad99f720579ad309c01591f2d4e765925aefd60bec2f66a28c14d5748b855 -size 10551 +oid sha256:4532dceab59523019eb7e0ca944c3c50278ef91eea6d6dc194bda743b9511fdf +size 10528 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png index 92b79113e1a..60a8334d917 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d527056f8cc0ae149aed408898cfc47cfdaba91cc7367e4cbf10fa49df44f9b -size 17781 +oid sha256:f644d1b6798c3761b0a16924a697cc653f31a090bab723177e40ec48131789a3 +size 19008 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png index f59422b33e7..9ddb94878dc 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9042bb2175fb555c005faa1f69bffd1640c91a3c109a18d4759d802d1540350 -size 7485 +oid sha256:bd1cd65b9fa83f0dc89331eee859ce08a3ac9444c815b4ddc572fd2953d38085 +size 7459 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png index 4a6e6655005..bdd255116a0 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ead7c23c723cbaf116629cfc502f4696454a1da1bdcb4aeaac29605d937d2605 -size 71610 +oid sha256:01d65375670bd50fdd58c2b105fecae6efb572df1a23e4d883361fba4ebd32af +size 71628 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png index 3ee771684aa..e3d68e573f4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e9d1d7021b65b18e7038e6551495b6d391381e85484827c005594fef3a038c7 -size 57984 +oid sha256:b0477ab88d07880dfef518acbeb6a020122797b4d1833bdf58f7a9a4342d93ad +size 58002 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png index 846a262c57a..1c308dc42cb 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68ef756dd45911d9a06f5ceb2ce4917d0d35ed0c9f1b6aa559fccef662672e38 -size 71118 +oid sha256:dd47b5f29485ade1aa9bbf1ef9f13b0be0b829f4b60f047bcdd616daa5044925 +size 71141 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png index c56359f7e45..e5feb7324da 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63eb5bc48de34efb71ffa644e78f18e0596c709f153b35fb6b7aec49dbdaab78 -size 79800 +oid sha256:1affca1d77e8929fe3abe279ae3bde06856313ef8dbcbe335905d50afc6493ac +size 81821 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png index 4551d86254c..7df51fb2557 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe419a00799214722644f17edb94aa4dda3bb7c3b4f19dad689c490bf05d550d -size 60676 +oid sha256:658ff95ae679d89fcb1b093617a46f3555bf242d44781a15c2a34c9f5a1a0c81 +size 60697 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png index 0beaf993fbd..b566c6f4491 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:864b6a01fbcd041a2cb5fee1602fceed5f311a1d73bab6fb7b10a3acf279f47f -size 59579 +oid sha256:5238f56c1cf3437ea6a8cae67497c3a80acdfd0842e6ab970bf398b422b40ba6 +size 59601 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png index 326f3948eda..acd556e96e1 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59a644589a3eb4ef7d791994caf7c6aae6b23a8964405db4d8e9ebba4fc26459 -size 66594 +oid sha256:de7995760d329c8425771d2d2fe461b817ae9b9e305275ca379cf95c1997d583 +size 66622 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png index f583680c902..a65ca8b35c6 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7185c0bbb80d40e3da31df878f5127947e5944dcf3ed81b8266342dea94a585d -size 88177 +oid sha256:4cf390d9a439b6df56e0ba58bef71fb7cc7abd4cd0a96f06307ea8d169ea9709 +size 88195 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png index 2de8f415758..cec8a0b12ac 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:957b3b1896978c725e03d8d8a22afb767fe032b8c10e499a70f0395eface7d12 -size 58909 +oid sha256:4112021940576375c6c32c2a5fb04f165368cfc7eb6ebdd2abdb3aabeb1cb707 +size 58930 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png index f97044e8b61..26bbc77e27b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:faa3cf430f066965686bf11dc9dcedd7a7161e18f6a96be566b8f9be615ee164 -size 58974 +oid sha256:91daf1d41634ff04960ca66a0acfcf2a337dd8e470c7d1f8f4aeea5df11685ab +size 58996 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png index a2a9d84ecae..a2bc71b494b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f21ce912bf7ec69db5cbb024c09d385cee117e0698b28432a54fb3c330d4b44c -size 66110 +oid sha256:236a64de0e67f91d2e3bc7e7442f99fd83f472a4be46c5f449d2e808ce4b6c43 +size 66132 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png index cdd4983e85f..738e779bad3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47521fcbb94b38913cdffa41faef1a0af40289576a892df84fb166b855a77cec -size 58441 +oid sha256:f7b9c8b0404e420411f22cab257c707ad1b0b50d9c42a35ff1253bcf753fd527 +size 58466 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png index f89f4b12b73..5236db62bf9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82904fd6abba5122b5b243e88e58bbf9a5c128128999afb4b536e689f114b967 -size 68610 +oid sha256:9e664f8e6f1e135c0243001e46e41a38ad72a9628c4960445f334ef87498715d +size 68511 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png index 4ccf9c59173..bc81f67b2be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06e63017d3636a943f015652d90089c6ded569930067dd8d6020df4053d98d3c -size 55340 +oid sha256:0043d71f6d17e9aa6392ff4ef28367e4f3cc9a5c637254b72adfd9c72cbfe3ad +size 55233 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png index aebee6a41b5..fc16867249d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4564c1019c830ef0bbed284bd9c8ac8dd1c8082dc76f89e394ae82a1756fa17 -size 68119 +oid sha256:0e24b826fa1fa408e6820078f0bb47fede7cf5d2a2bfae55978739e47084c249 +size 68028 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png index 42f7ea79a33..08e85044492 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:713af5b3d329a98e75e9587b8b228e1c017a342dc5bc4663df5d0aebf4951637 -size 76948 +oid sha256:15120e28f72ffbc1b035509b747d0137d56b6b0520341baad7f8ba090fb700b9 +size 78666 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png index 7313e32cf01..df3b32b8d2a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bf814a838410e92a932373b39aa91deb53b18857055770ffa2c1687d6128e37 -size 57989 +oid sha256:a55a88cef50cbfd1357f51e572093a84e056d0a93a12bd33fb25ba5f370e4615 +size 57901 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png index a3726143262..d460968d64d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:728842933007c366c10e0293a22b0722bf2436c43e3ced69bb78838fef4e4aac -size 56944 +oid sha256:6531f685230484610a5e12fade57ad8266c431b5a19f7b3eee77c15b75c96e38 +size 56848 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png index 639cfeee1f8..6dc48a06304 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55c8b429bfc0d518cde9b16127adf05fb23a29f70df2572a6212c3f837f36133 -size 63826 +oid sha256:d193184265e4d575a34a6820bc77482a126aac0c43cfd0bc09152906ef6fa601 +size 63732 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png index bdf1ea58853..d0b1d953ff5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb8e8cf4624d8b6a75db331dd43fb4b51df2723f98eccdc4d87bc1d4df80c3a8 -size 84918 +oid sha256:62e37482c9ace9e4332df8aa650d12c7300a2cab05194b825184dc39dd926772 +size 84860 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png index 465d5a5c112..c86ff3c07e2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c3c802c57eeb15b14ab02677a84b422238aa08515b9151d9e01c8aefc0327b5 -size 56322 +oid sha256:6c5edf80cef291b62f4c3f125e3c2e1073e5e6db40de4b826f83aece9ff55af8 +size 56221 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png index 3ba841381fd..dab20f0c93e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7995253a70c47aca2215d475549c5da8a5ecd6331f8e1d2268df6979cd5fbc01 -size 56276 +oid sha256:5787b8a8b31173fae71bd513d84d02467d154917d4a2d2825424ca868c8bd101 +size 56171 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png index ee775246b6b..523ffd0d505 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3868e61ecafeb234c4e3438c138ac420192a569949fc8f4f8e4c946ba42f14 -size 63243 +oid sha256:acf87ea3c8f6db07c1758861d3e4849295538c741393fda18759d7f66225cea0 +size 63155 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png index 861384a18ea..8b1d78f6015 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1912b608b35257618b42d8abb602f031b67fcbc9810e59e6b20bc86075002b5d -size 55847 +oid sha256:25f1ebd9f70c477df9b1159fd92becf0d5ec9139c36c081faa6af862b388e42e +size 55735 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png index b78d36fdd42..a7d1026e416 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:745714bf11601a9abedc9526d3ddfcbfebc1ed8d73872ebd33f10cc638119c19 -size 72647 +oid sha256:3ac4707a32f7680319d1026d9dd6f56de74a95c4a4f990b0c1adf5280fa1a1c7 +size 72582 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png index 5ef7a3ecaa7..319cf76ebc8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a55cd98bc72c250706402bfac0706da3adc60a23010ab3a634290f2115f8543 -size 56527 +oid sha256:621b28e55b2a2a6894c4eed975c4aafccf25e77ece0db90621e639c9718adbfb +size 56478 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png index 94488a81183..be729307b01 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f4ec0a26c52319ec4c0e3468115c977bf3d4be116fa29d5f234758978b518cc -size 70973 +oid sha256:1f56ca941457d2e53a3659e516a7a27da56071820b221c251ff89a231185320a +size 70911 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png index 31cf97c9c44..568ba4e7b77 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a34d90d08ce5cd10a239c3accb0a92abeeb5309ac73a079700ded09ed1ee1697 -size 82282 +oid sha256:c718cd9d08c6ff483a2567ff6795803574a873091c5a1b984a08e1873e691c65 +size 84530 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png index 7cb03f9aac6..9a40df428fa 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d2c24321d4e7e827aa56ec9715d591b0f47091dab02b13c1dc045a175a70154 -size 59640 +oid sha256:7aa00fb0b59a871158c44cb67a4536aa2318e215add4fabf221726406e9f2781 +size 59617 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png index e008b3ee4e2..a55b92e8b8a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7727ad7b23e98365dbf7b30f2375500cdbff8062d17a1909b166040e07281a0 -size 58638 +oid sha256:3f4d668e7f9fa7d294cb1de945dd70bfd1b394f14159705ad37c0f55cae2c6e7 +size 58611 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png index ba46e2e270a..35f2d39b793 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b514f0f3c246531c3bab262d9b85f60eb737b960c0f1401c0c8d684d75a4f1d9 -size 66694 +oid sha256:22d2ab3ba2f9e04510e7ea7d185baaf305d12a3548956a695fd72c3d7e181d57 +size 66671 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png index 0c232804432..437e1acae5f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a84a52cd5320adfa2bc77781d7d5b996b21a4dcc94d2800c66e636a06f40a40 -size 101578 +oid sha256:890b7338df518463862560114113d64504383c01c2e6db6800c85c082637f496 +size 101548 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png index 5b5b142b81d..7562d7090bd 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:795ab6fd6386131a124707e96d38a916dca88fa081562cbaef269f6831fc55ca -size 57855 +oid sha256:5c30ee7afcc5cb4c1a405a026a4ce3b36e645b5b720b39531c1b8b979e6961ec +size 57801 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png index 9de8ba3f72f..883800c68f3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cae668f5a1adfbe42e44ea472dbf7dc5079ec1c3826507859d3e0536766fdb39 -size 57700 +oid sha256:1f1e64891abde888d1ad06bfb24ad4ba1aafcc2eb7c9a573a1deb116761b4080 +size 57688 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png index e7784793c45..a5ed775376a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e704bc61a2ae26cfdc30262a4e43122f24faa2a546ae7f16ad15560fc615327 -size 66960 +oid sha256:dc00781c5eafbbc1f574506b7948eb64d9784f6c69f8c0e29be5d120f39b366d +size 66942 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png index 5b56c20157c..4eac81e3047 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:919a9159a4a6b82d65e2f0a59cf55031620f32fe4066aca9440ffdd9f97ccd01 -size 57246 +oid sha256:be145b0f36fb88eb76a15d90ebdd2edf89ec99c84112216ffdfd2bdae9a850af +size 57204 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png index b18545b512a..06ba9eb8731 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:758cd0c11cb3d951b7c6db787bbaf66dc87252758c6bc9b36e0d1b479fd495e8 -size 69281 +oid sha256:9ddb88b02eaed6c732e11dd26e3f45506d037d4e113db171c9283a2fab4f9bd7 +size 69203 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png index 6ec2ca88f62..d9e77e9bd41 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56711e24449f15fa13b6147d1087180bc18f9a873eb0a96d19acfbaae66f74fa -size 53284 +oid sha256:1d456410474fb03e3a0bfcd2d737b2f0333e56d26c3eddd3515bcf8031a17d59 +size 53155 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png index 638136dddae..5c44befebb2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b598d4cb370923b7aa28287247c08131b773aee20467dd5d3ce95d0506e26ec6 -size 67706 +oid sha256:ebd0a8984dc9ced8ea84180862034c531bd2713dba6e35729d2686921a01b3d8 +size 67634 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png index ccab6d1cc62..f3f6ee2ee89 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d0300a4ba4a025b29e37cf01e29d9cd77ac411fe00642c64a9c14ff80ee4149 -size 79018 +oid sha256:eb3bb205e9cf382961dc003ee33248a3dc87089060dffaadee6d2af3c842dd1a +size 80952 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png index c2a4773ab40..a438c1fdfec 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3224a33cd9db2adf69479e8acfbaacaabd0d8ba00d4d862f1b38fc8e360539bf -size 56546 +oid sha256:ee8618bb0da86007b0e50f957f7d941bd5f31332a021e36e7641f95d32dc2108 +size 56440 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png index a01a166e951..132c4d7b5ff 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2cbbf5adf4d45fa65aa176b55216f8dff89114ef1a5b4de57bc86a53a22f012 -size 55584 +oid sha256:b912c22474b75a78b951a071b9217e1fc25e2f2d49916a4724399fb898c4a4ef +size 55462 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png index d9592ab2d05..9da26c1c14c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49ee949c20152ff3c9ecef1bd361b223fc4eff62a420740d9b708343f5c6121 -size 63442 +oid sha256:99e13306414846ce38775a5b681562f6e79d2e8d860310e4dda1d1fb3aaac60f +size 63341 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png index e6f0772abc4..dab02cddf24 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46c32f65a5071767b8603f93711f8c3c6150bfc9f6c0f6fd61317aafdebb2e83 -size 97701 +oid sha256:92d0ab372ab8efb76da83c9e28fb5f2d6a4d202285f9fe0644d378d02f8e11ba +size 97602 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png index f18f28fa0f7..e9c013be927 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2baad13bfc9fd755108365a9b0f7251f60cc8f9bf335115ab5c02b2d0f19f74 -size 54660 +oid sha256:1579ecd04cfde9b62d85287014e519fbb04745d9253140e2cd9936f4a23dbe56 +size 54540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png index 381119052c9..9e0186982de 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a735f4a7003f631c230c29e4248d186d86111ada2c4c4cfc8e6e4fe9897b224 -size 54424 +oid sha256:e11143794228c270b85db674577948d52161fe1c16c8ae8ff04870636ee58e4d +size 54317 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png index 16da419a33a..a53692356be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c109f03989aaa14873823c230bb262add95099b44031512ee29b9f8c8e7d180f -size 63642 +oid sha256:8c51275bdbf340dafbeac16ba6c946c5ea8fbd829a8b1ad45f9c2d1da0a28df9 +size 63540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png index dfa13081114..41f97a01551 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbe7caa3bd8b8788befae4c4febbcd1bb02bd2ea9d6feb00bffb962d0f2885d6 -size 54080 +oid sha256:a99c944c69b41ce89be07fb0c183ef0588b4b0540c747dd40a1135ffcf8d1216 +size 53962 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png index 3806b4217cb..cb6393e72be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d35ab62460942f4f14edc87aab84f4d659b18763c4cf8a1c353f99085c1c164d -size 16727 +oid sha256:2508c7785b3dc7f2d2e0abd45c2556c8bbd9a3905179f1082fe66b296d7b4752 +size 17282 diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index cc99ecab5a8..79bfeb7250e 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -227,6 +227,7 @@ Compose: # You can optionally define a list of CompositionLocals that are allowed here allowedCompositionLocals: - LocalCompoundColors + - LocalEventContentValidationState - LocalSnackbarDispatcher - LocalCameraPositionState - LocalMediaItemPresenterFactories