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 0000000000..cc5fca38a0 --- /dev/null +++ b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt @@ -0,0 +1,23 @@ +/* + * 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 (FOSS) always returns a valid state. + * This will only process media when a [ContentScanner] is provided when building the client in Pro. + */ +fun interface ContentScannerService { + fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) +} diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts index 53e8843b1d..34558a182e 100644 --- a/features/contentscanner/impl/build.gradle.kts +++ b/features/contentscanner/impl/build.gradle.kts @@ -32,4 +32,5 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) } 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 0000000000..5cc8b9e315 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt @@ -0,0 +1,56 @@ +/* + * 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.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.launch +import java.io.IOException + +/** + * Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources. + */ +class DefaultContentScannerService( + private val contentScanner: ContentScanner, + private val coroutineScope: CoroutineScope, + coroutineDispatchers: CoroutineDispatchers, +) : ContentScannerService { + private val context = coroutineDispatchers.io.limitedParallelism(4) + + override fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) { + for (mediaSource in mediaSources) { + val url = mediaSource.safeUrl + val currentState = contentValidationState.getCurrentMediaState(url) + if (currentState != ContentValidationValue.Unknown) continue + contentValidationState.update(url, ContentValidationValue.Loading) + + coroutineScope.launch(context) { + 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 store the error + 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 0000000000..af4618dd28 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt @@ -0,0 +1,39 @@ +/* + * 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.api.scanner.ContentScanner +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 io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultEventContentValidationCache( + private val contentScanner: ContentScanner?, +) : EventContentValidationCache { + private val cache = ConcurrentHashMap() + + override operator fun get(eventId: EventId): ContentValidationState { + return cache.getOrPut(eventId) { + if (contentScanner != null) { + DefaultContentValidationState() + } else { + Timber.v("Content scanner is not available, returning NoopContentValidationState for eventId: $eventId") + NoopContentValidationState() + } + } + } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/AlwaysValidContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/AlwaysValidContentScannerService.kt new file mode 100644 index 0000000000..6170f3e153 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/AlwaysValidContentScannerService.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 AlwaysValidContentScannerService : 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/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 0000000000..9446a603b2 --- /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.RoomCoroutineScope +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?, + @RoomCoroutineScope coroutineScope: CoroutineScope, + coroutineDispatchers: CoroutineDispatchers, + ): ContentScannerService { + return if (contentScanner != null) { + DefaultContentScannerService( + contentScanner = contentScanner, + coroutineScope = coroutineScope, + coroutineDispatchers = coroutineDispatchers, + ) + } else { + AlwaysValidContentScannerService() + } + } +} diff --git a/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerServiceTest.kt b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerServiceTest.kt new file mode 100644 index 0000000000..accdf0ff37 --- /dev/null +++ b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerServiceTest.kt @@ -0,0 +1,217 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.matrix.test.scanner.FakeContentScanner +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.NoopContentValidationState +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.FileNotFoundException + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultContentScannerServiceTest { + @Test + fun `scan with no media sources returns valid`() = runTest { + val scanLambda = lambdaRecorder> { Result.success(true) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + createDefaultContentScannerService( + contentScanner = scanner, + ).scan( + eventId = AN_EVENT_ID, + mediaSources = emptyList(), + contentValidationState = contentValidationState, + ) + + scanLambda.assertions().isNeverCalled() + assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid) + } + + @Test + fun `scan with single media source`() = runTest { + val scanLambda = lambdaRecorder> { Result.success(false) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + + val mediaSource = aMediaSource("https://example.com/media.jpg") + createDefaultContentScannerService( + contentScanner = scanner, + ).scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + runCurrent() + + scanLambda.assertions().isCalledOnce().with(value(mediaSource)) + assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid) + } + + @Test + fun `scan with several media sources`() = runTest { + val mediaSourceA = aMediaSource("https://example.com/mediaA.jpg") + val mediaSourceB = aMediaSource("https://example.com/mediaB.jpg") + + val scanLambda = lambdaRecorder> { source -> + when (source) { + mediaSourceA -> Result.success(true) + mediaSourceB -> Result.success(false) + else -> error("Unexpected media source: $source") + } + } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + + createDefaultContentScannerService( + contentScanner = scanner, + ).scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSourceA, mediaSourceB), + contentValidationState = contentValidationState, + ) + + runCurrent() + + scanLambda.assertions().isCalledExactly(2) + assertThat(contentValidationState.getMediaStateFlow(mediaSourceA.safeUrl).first()).isEqualTo(ContentValidationValue.Valid) + assertThat(contentValidationState.getMediaStateFlow(mediaSourceB.safeUrl).first()).isEqualTo(ContentValidationValue.Invalid) + assertThat(contentValidationState.overallStateFlow.first()).isEqualTo(ContentValidationValue.Invalid) + } + + @Test + fun `scan won't be repeated for sources which are already being scanned`() = runTest { + val scanLambda = lambdaRecorder> { Result.success(false) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + + val mediaSource = aMediaSource("https://example.com/media.jpg") + val service = createDefaultContentScannerService( + contentScanner = scanner, + ) + + // First scan attempt + service.scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + // Immediately after, a new scan attempt is made for the same media source + service.scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + runCurrent() + + // The scan action was done just once, and the overall state is invalid as expected + scanLambda.assertions().isCalledOnce().with(value(mediaSource)) + assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid) + } + + @Test + fun `scan won't be repeated for sources which already have a cached value`() = runTest { + val scanLambda = lambdaRecorder> { Result.success(false) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = NoopContentValidationState(ContentValidationValue.Valid) + + val mediaSource = aMediaSource("https://example.com/media.jpg") + val service = createDefaultContentScannerService( + contentScanner = scanner, + ) + + service.scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + runCurrent() + + scanLambda.assertions().isNeverCalled() + } + + @Test + fun `if a scan fails with a recoverable error, the content validation value will be back to Unknown`() = runTest { + val scanLambda = lambdaRecorder> { Result.failure(FileNotFoundException("Some IO exception")) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + + val mediaSource = aMediaSource("https://example.com/media.jpg") + val service = createDefaultContentScannerService( + contentScanner = scanner, + ) + + service.scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + runCurrent() + + scanLambda.assertions().isCalledOnce().with(value(mediaSource)) + assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.Unknown) + assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown) + } + + @Test + fun `if a scan fails with an unrecoverable error, the content validation value will be back to UnrecoverableError`() = runTest { + val error = OutOfMemoryError("BOOM") + val scanLambda = lambdaRecorder> { Result.failure(error) } + val scanner = FakeContentScanner(scan = scanLambda) + val contentValidationState = DefaultContentValidationState() + + val mediaSource = aMediaSource("https://example.com/media.jpg") + val service = createDefaultContentScannerService( + contentScanner = scanner, + ) + + service.scan( + eventId = AN_EVENT_ID, + mediaSources = listOf(mediaSource), + contentValidationState = contentValidationState, + ) + + runCurrent() + + scanLambda.assertions().isCalledOnce().with(value(mediaSource)) + assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.UnrecoverableError(error)) + assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.UnrecoverableError(error)) + } + + private fun TestScope.createDefaultContentScannerService( + contentScanner: ContentScanner = FakeContentScanner(), + sessionCoroutineScope: CoroutineScope = backgroundScope, + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + ): DefaultContentScannerService { + return DefaultContentScannerService( + contentScanner = contentScanner, + coroutineScope = sessionCoroutineScope, + coroutineDispatchers = coroutineDispatchers + ) + } +} diff --git a/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCacheTest.kt b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCacheTest.kt new file mode 100644 index 0000000000..5a7bfa07b9 --- /dev/null +++ b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCacheTest.kt @@ -0,0 +1,44 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.scanner.FakeContentScanner +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState +import org.junit.Test + +class DefaultEventContentValidationCacheTest { + @Test + fun `get returns NoopContentValidationState when ContentScanner is null`() { + // Given + val contentScanner: ContentScanner? = null + val cache = DefaultEventContentValidationCache(contentScanner) + + // When + val state = cache[AN_EVENT_ID] + + // Then + assertThat(state).isInstanceOf(NoopContentValidationState::class.java) + } + + @Test + fun `get returns an actual DefaultContentValidationState when ContentScanner is not null`() { + // Given + val contentScanner: ContentScanner? = FakeContentScanner() + val cache = DefaultEventContentValidationCache(contentScanner) + + // When + val state = cache[AN_EVENT_ID] + + // Then + assertThat(state).isInstanceOf(DefaultContentValidationState::class.java) + } +} diff --git a/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt new file mode 100644 index 0000000000..8c3834d696 --- /dev/null +++ b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt @@ -0,0 +1,54 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.features.contentscanner.impl.DefaultContentScannerService +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.test.scanner.FakeContentScanner +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ContentScannerModuleTest { + @Test + fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest { + // Given + val contentScanner: ContentScanner? = null + val sessionCoroutineScope = backgroundScope + val coroutineDispatchers = testCoroutineDispatchers() + + // When + val contentScannerService = ContentScannerModule.providesContentScannerService( + contentScanner = contentScanner, + coroutineScope = sessionCoroutineScope, + coroutineDispatchers = coroutineDispatchers, + ) + + // Then + assertThat(contentScannerService).isInstanceOf(AlwaysValidContentScannerService::class.java) + } + + @Test + fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest { + // Given + val contentScanner: ContentScanner? = FakeContentScanner() + val sessionCoroutineScope = backgroundScope + val coroutineDispatchers = testCoroutineDispatchers() + + // When + val contentScannerService = ContentScannerModule.providesContentScannerService( + contentScanner = contentScanner, + coroutineScope = sessionCoroutineScope, + coroutineDispatchers = coroutineDispatchers, + ) + + // Then + assertThat(contentScannerService).isInstanceOf(DefaultContentScannerService::class.java) + } +} diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 2661f7e330..116a3a7c41 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 cbab26fe54..88a90f6c7d 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 a7e8846047..18c02d8f40 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 8887424dc6..ec014a4ff6 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 eaa00f427e..80dd21b7b3 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 b4fe504ab1..bdad9ba631 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 5330fc00d3..cfc3672ca3 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 00039dc1af..9a33299cd0 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/ContentPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt index f7c3627b2a..d6f2bd3161 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt @@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components enum class ContentPadding { Textual, Media, - CaptionedMedia + CaptionedMedia, + InvalidContent, } 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 80bd342d01..1ebdc34c6c 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/TimelineItemContentScannerPreviews.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemContentScannerPreviews.kt new file mode 100644 index 0000000000..5c5414646d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemContentScannerPreviews.kt @@ -0,0 +1,320 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview +import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem +import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent +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.aTimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem +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.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +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.matrix.ui.media.contentvalidation.NoopContentValidationState +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails + +private val AN_EVENT_ID = EventId($$"$eventId") + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewScanningContentPreview() { + ElementTimelineItemPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent())) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAudioViewScanningContentPreview() { + ElementTimelineItemPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent())) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading))) + } + ElementTimelineItemPreview { + CompositionLocalProvider( + LocalEventContentValidationState provides cache, + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemScanningContentFailedPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent())) + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption"))) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ATimelineItemEventRow( + event = aTimelineItemEvent( + content = aTimelineItemTextContent(), + inReplyTo = inReplyToInvalidContent(), + ) + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemImageContent(), + inReplyTo = inReplyToInvalidContent(), + ) + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemImageContent(), + inReplyTo = inReplyToTextContent(), + ) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")))) + ) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ATimelineItemEventRow( + event = aTimelineItemEvent( + content = aTimelineItemTextContent(), + inReplyTo = inReplyToInvalidContent(), + ) + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemImageContent(), + inReplyTo = inReplyToInvalidContent(), + ) + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemImageContent(), + inReplyTo = inReplyToTextContent(), + ) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache( + initial = + mapOf( + AN_EVENT_ID to DefaultContentValidationState(mapOf( + "invalid" to ContentValidationValue.Invalid, + "error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")), + "" to ContentValidationValue.Valid + )) + ) + ) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(), + aGalleryItem(mediaSource = MediaSource("invalid", "{}")), + aGalleryItem(), + aGalleryItem(mediaSource = MediaSource("invalid", "{}")), + aGalleryItem(mediaSource = MediaSource("error", "{}")), + aGalleryItem(mediaSource = MediaSource("invalid", "{}")), + aGalleryItem(), + ) + ) + ) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview { + val cache = remember { + InMemoryEventContentValidationCache( + initial = + mapOf( + AN_EVENT_ID to DefaultContentValidationState(mapOf( + "invalid" to ContentValidationValue.Invalid, + "error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")), + "" to ContentValidationValue.Valid + )) + ) + ) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent( + eventId = AN_EVENT_ID, + content = aTimelineItemAttachmentsContent( + attachments = listOf( + anAttachmentItem(), + anAttachmentItem(mediaSource = MediaSource("invalid", "{}")), + anAttachmentItem(), + anAttachmentItem(), + anAttachmentItem(mediaSource = MediaSource("error", "{}")), + anAttachmentItem(mediaSource = MediaSource("invalid", "{}")), + ) + ) + ) + ) + } +} + +private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready( + eventId = AN_EVENT_ID, + senderId = UserId("@sender:matrix.org"), + eventContent = MessageContent( + body = "A body", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = ImageMessageType( + filename = "A file", + caption = "A caption", + formattedCaption = null, + source = MediaSource("", ""), + info = null, + ) + ), + textContent = "A text content", + senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null) +) + +private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready( + eventId = EventId($$"$text_eventId"), + senderId = UserId("@sender:matrix.org"), + eventContent = MessageContent( + body = "A body", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType( + body = "A body", + formatted = null, + ) + ), + textContent = "A text content", + senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null) +) 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 c73248af53..30053294da 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,12 @@ 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.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 +91,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 +124,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 +182,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 +474,23 @@ 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.hasError() && + event.content.isMedia + + // If the event has a dangerous media content we need to set custom message bubble background and border colors + val themeColors = ElementTheme.colors + val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) { + val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations } + val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations } + background to border + } + // Message bubble val bubbleState = BubbleState( groupPosition = event.groupPosition, @@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent( interactionSource = interactionSource, onClick = onContentClick, onLongClick = onLongClick, + customBackgroundColor = dangerousContentBubbleColor, + borderColor = borderColor, ) { MessageEventBubbleContent( event = event, @@ -747,6 +772,7 @@ private fun MessageEventBubbleContent( } ContentPadding.CaptionedMedia -> Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp) + ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp) } val threadDecoration = @Composable { @@ -770,10 +796,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 +811,25 @@ private fun MessageEventBubbleContent( } else { inReplyToModifier.clickable(onClick = inReplyToClick) } + + val contentHasError = currentContentValidationState.hasError() + val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary + val backgroundColor = if (contentHasError) 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,30 +849,47 @@ 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.hasError() + + 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 -> if (content.showCaption) TimestampPosition.Aligned else 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 - is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia - is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia - is TimelineItemStickerContent, - is TimelineItemLocationContent -> ContentPadding.Media - else -> ContentPadding.Textual + + val paddingBehaviour = if (needsInvalidContentLayout) { + ContentPadding.InvalidContent + } else { + 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 + is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia + is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia + is TimelineItemStickerContent, + is TimelineItemLocationContent -> ContentPadding.Media + else -> ContentPadding.Textual + } } CommonLayout( showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse, 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 a49952b272..024169b00a 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 @@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup 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 @@ -62,9 +61,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, @@ -134,9 +133,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 6a5cbfd9dd..20444903ad 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 a6015758fc..eb27134065 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 4aa41a472b..38d575718f 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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 @@ -25,8 +24,10 @@ 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.ContentValidationValue /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. @@ -38,9 +39,9 @@ fun TimelineItemAttachmentView( iconContentDescription: String?, filename: String, fileExtensionAndSize: String, - caption: String?, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationValue: ContentValidationValue = ContentValidationValue.Valid, ) { Column( modifier = modifier, @@ -50,16 +51,9 @@ fun TimelineItemAttachmentView( iconContentDescription = iconContentDescription, filename = filename, fileExtensionAndSize = fileExtensionAndSize, - hasCaption = caption != null, + isLoading = !contentValidationValue.isValidated(), onContentLayoutChange = onContentLayoutChange, ) - if (caption != null) { - TimelineItemAttachmentCaptionView( - modifier = Modifier.padding(top = 4.dp), - caption = caption, - onContentLayoutChange = onContentLayoutChange, - ) - } } } @@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView( iconContentDescription: String?, filename: String, fileExtensionAndSize: String, - hasCaption: Boolean, + isLoading: Boolean, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -86,12 +80,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( @@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView( style = ElementTheme.typography.fontBodySmRegular, maxLines = 1, overflow = TextOverflow.Ellipsis, - onTextLayout = if (hasCaption) { - {} - } else { - ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChange = onContentLayoutChange, - extraWidth = iconSize + spacing - ) - }, + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + extraWidth = iconSize + spacing + ), ) } } } - -@Composable -private fun TimelineItemAttachmentCaptionView( - caption: String, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, - modifier: Modifier = Modifier, -) { - Text( - modifier = modifier, - text = caption, - color = ElementTheme.colors.textPrimary, - style = ElementTheme.typography.fontBodyLgRegular, - onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChange = onContentLayoutChange, - ) - ) -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt index 9bc554057f..c63d68e062 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt @@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider Unit, - onLinkClick: (Link) -> Unit, - onLinkLongClick: (Link) -> Unit, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - content.attachments.forEachIndexed { index, attachment -> - Column { - if (index > 0) { - HorizontalDivider( - color = ElementTheme.colors.borderInteractiveSecondary, + val contentValidationState = rememberEventContentValidationState( + eventId = eventId, + needsValidation = content.isMedia, + ) + + Column(modifier = modifier.fillMaxWidth()) { + val validationStates = remember(content.attachments) { + buildList { + for (attachment in content.attachments) { + add(validationStateForAttachment(attachment, contentValidationState)) + } + }.toMutableStateList() + } + content.attachments.forEachIndexed { index, attachment -> + 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) { + validationStateForAttachment(attachment, contentValidationState) + } + + LaunchedEffect(itemContentValidationState) { + validationStates[index] = itemContentValidationState + } + + val needsSeparator = index in 1..max(1, content.attachments.lastIndex) + val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true + if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) { + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, + ) + } + + when (itemContentValidationState) { + ContentValidationValue.Invalid -> { + val shape = RoundedCornerShape(6.dp) + InvalidContentView( + modifier = Modifier + .padding(vertical = 6.dp) + .border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape) + .clip(shape), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), ) } - AttachmentListItem( - attachment = attachment, - onClick = { onGalleryItemClick(index) }, + is ContentValidationValue.UnrecoverableError -> { + val shape = RoundedCornerShape(6.dp) + NotFoundContentView( + modifier = Modifier + .padding(vertical = 6.dp) + .border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape) + .clip(shape), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.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 + } + } + + if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) { + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, ) } } } - if (content.showCaption) { - HorizontalDivider( - color = ElementTheme.colors.borderInteractiveSecondary, - ) - Spacer(modifier = Modifier.height(8.dp)) - val caption = if (LocalInspectionMode.current) { - SpannedString(content.caption) - } else { - content.formattedCaption ?: SpannedString(content.caption) - } - CompositionLocalProvider( - LocalContentColor provides ElementTheme.colors.textPrimary, - LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular - ) { - EditorStyledText( - modifier = Modifier - .padding(horizontal = 4.dp) - .widthIn(min = 120.dp), - text = caption, - style = ElementRichTextEditorStyle.textStyle(), - onLinkClickedListener = onLinkClick, - onLinkLongClickedListener = onLinkLongClick, - releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), - ) - } - } + } +} + +private fun validationStateForAttachment( + attachment: AttachmentItem, + contentValidationState: ContentValidationState, +): ContentValidationValue { + val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown + val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl) + return validationStateForAttachment(thumbnailValue, mediaValue) +} + +private fun validationStateForAttachment( + thumbnailValue: ContentValidationValue, + mediaValue: ContentValidationValue, +): ContentValidationValue { + return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) { + ContentValidationValue.Invalid + } else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) { + listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() } + } else if (thumbnailValue.isLoading() || mediaValue.isLoading()) { + ContentValidationValue.Loading + } else { + mediaValue } } @@ -213,10 +268,8 @@ internal fun TimelineItemAttachmentsListViewPreview( @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent ) = ElementPreview { TimelineItemAttachmentsListView( + eventId = EventId("\$eventId"), content = content, onGalleryItemClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } 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 d5a07194cf..1f10a97eda 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 @@ -16,21 +16,23 @@ 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.ContentValidationValue @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationValue: ContentValidationValue = ContentValidationValue.Valid, ) { TimelineItemAttachmentView( icon = CompoundIcons.Audio(), iconContentDescription = null, filename = content.filename, fileExtensionAndSize = content.fileExtensionAndSize, - caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationValue = contentValidationValue, ) } 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 2951d13d65..c622a3044a 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 @@ -8,9 +8,23 @@ package io.element.android.features.messages.impl.timeline.components.event +import android.text.SpannedString +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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 androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent +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.rememberPresenter @@ -32,132 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.captionOrNull import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation +import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull +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.designsystem.components.EqualWidthColumn +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.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView +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.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.wysiwyg.compose.EditorStyledText 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 presenterFactories = LocalTimelineItemPresenterFactories.current - when (content) { - is TimelineItemEncryptedContent -> TimelineItemEncryptedView( - content = content, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) - is TimelineItemRedactedContent -> TimelineItemRedactedView( - content = content, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) - is TimelineItemTextBasedContent -> TimelineItemTextView( - content = content, - modifier = modifier, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange - ) - is TimelineItemUnknownContent -> TimelineItemUnknownView( - content = content, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier - ) - is TimelineItemLocationContent -> { - TimelineItemLocationView( - content = content.ensureActiveLiveLocation(), - onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, - modifier = modifier - ) + val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) { + timelineProtectionState.hideMediaContent(eventId) + } + + val onShowContentClick = remember(timelineProtectionState.eventSink) { + { + timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId)) } - is TimelineItemImageContent -> TimelineItemImageView( - content = content, - hideMediaContent = hideMediaContent, - onContentClick = onContentClick, - onLongClick = onLongClick, - onShowContentClick = onShowContentClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier, - ) - is TimelineItemGalleryContent -> TimelineItemGalleryView( - content = content, - onGalleryItemClick = { index -> onGalleryItemClick(index) }, - onLongClick = onLongClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier, - ) - is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( - content = content, - onGalleryItemClick = { index -> onGalleryItemClick(index) }, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - 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 TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) - is TimelineItemStateContent -> TimelineItemStateView( - content = content, - modifier = modifier - ) - is TimelineItemPollContent -> TimelineItemPollView( - content = content, - eventSink = eventSink, - modifier = modifier, - ) - is TimelineItemVoiceContent -> { - val presenter: Presenter = presenterFactories.rememberPresenter(content) - TimelineItemVoiceView( - state = presenter.present(), - content = content, + } + + val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia) + val overallValidationState by contentValidationState.collectOverallState() + val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent } + + if (eventId != null) { + ValidateMediaHelper(eventId, content, contentValidationState, eventSink) + } + + val caption = content.captionOrNull() + val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent + // If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content + val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) { + if (showCaption) { + {} + } else { + onContentLayoutChange + } + } + + val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem + val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem + EqualWidthColumn(modifier = modifier) { + if (displayInvalidContent) { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = calculatedOnContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + ) + } else if (displayContentNotFound) { + NotFoundContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = calculatedOnContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + ) + } else { + val presenterFactories = LocalTimelineItemPresenterFactories.current + when (content) { + is TimelineItemEncryptedContent -> TimelineItemEncryptedView( + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + ) + is TimelineItemRedactedContent -> TimelineItemRedactedView( + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + ) + is TimelineItemTextBasedContent -> TimelineItemTextView( + content = content, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = calculatedOnContentLayoutChange, + ) + is TimelineItemUnknownContent -> TimelineItemUnknownView( + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + ) + is TimelineItemLocationContent -> { + TimelineItemLocationView( + content = content.ensureActiveLiveLocation(), + onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, + ) + } + is TimelineItemImageContent -> { + TimelineItemImageView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + contentValidationState = contentValidationState, + ) + } + is TimelineItemStickerContent -> { + TimelineItemStickerView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowClick = onShowContentClick, + contentValidationState = contentValidationState, + ) + } + is TimelineItemVideoContent -> { + TimelineItemVideoView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + contentValidationState = contentValidationState, + ) + } + is TimelineItemGalleryContent -> TimelineItemGalleryView( + eventId = eventId, + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + onLongClick = onLongClick, + ) + is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + eventId = eventId, + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + ) + is TimelineItemFileContent -> { + TimelineItemFileView( + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + contentValidationValue = overallValidationState, + ) + } + is TimelineItemAudioContent -> { + TimelineItemAudioView( + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + contentValidationValue = overallValidationState, + ) + } + is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView() + is TimelineItemStateContent -> TimelineItemStateView( + content = content, + ) + is TimelineItemPollContent -> TimelineItemPollView( + content = content, + eventSink = eventSink, + ) + is TimelineItemVoiceContent -> { + val presenter: Presenter = presenterFactories.rememberPresenter(content) + TimelineItemVoiceView( + state = presenter.present(), + content = content, + onContentLayoutChange = calculatedOnContentLayoutChange, + contentValidationValue = overallValidationState, + ) + } + is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") + } + } + + if (showCaption) { + val padding = if (displayInvalidContent) { + PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 0.dp) + } else { + when (content) { + is TimelineItemImageContent, + is TimelineItemVideoContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent -> PaddingValues(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 0.dp) + else -> PaddingValues(horizontal = 12.dp, vertical = 8.dp) + } + } + CaptionView( + modifier = Modifier.padding(padding), + caption = caption, + formattedCaption = content.formattedCaptionOrNull(), + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, onContentLayoutChange = onContentLayoutChange, - modifier = modifier ) } - is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") + } +} + +@Composable +private fun CaptionView( + caption: String, + formattedCaption: CharSequence?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val caption = if (LocalInspectionMode.current) { + SpannedString(caption) + } else { + formattedCaption ?: SpannedString(caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + EditorStyledText( + modifier = modifier, + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + ) + } +} + +@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 7f8be6e60a..a0deec6d84 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 @@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont 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.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.ui.strings.CommonStrings /** @@ -27,15 +28,16 @@ fun TimelineItemFileView( content: TimelineItemFileContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationValue: ContentValidationValue = ContentValidationValue.Valid, ) { TimelineItemAttachmentView( icon = CompoundIcons.Attachment(), iconContentDescription = stringResource(CommonStrings.common_file), filename = content.filename, fileExtensionAndSize = content.fileExtensionAndSize, - caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationValue = contentValidationValue, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt index fe7de6a7f6..9d88d8c0fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt @@ -116,6 +116,7 @@ fun aGalleryItem( width: Int = 400, height: Int = 300, duration: Duration = Duration.ZERO, + mediaSource: MediaSource = MediaSource(url = "", json = ""), ): GalleryItem { return GalleryItem( filename = filename, @@ -125,7 +126,7 @@ fun aGalleryItem( GalleryItem.Type.File -> "application/pdf" GalleryItem.Type.Image -> "image/jpeg" }, - mediaSource = MediaSource(url = "", json = ""), + mediaSource = mediaSource, thumbnailSource = null, width = width, height = height, 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 c51055da92..8c4244a651 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 @@ -7,7 +7,6 @@ package io.element.android.features.messages.impl.timeline.components.event -import android.text.SpannedString import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -21,28 +20,24 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.LocalContentColor -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +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 import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -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.GalleryItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground @@ -50,10 +45,11 @@ 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.textcomposer.ElementRichTextEditorStyle +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.ui.utils.time.formatShort -import io.element.android.wysiwyg.compose.EditorStyledText -import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList private const val MAX_TILES = 5 @@ -67,12 +63,10 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp @Composable fun TimelineItemGalleryView( + eventId: EventId?, content: TimelineItemGalleryContent, onGalleryItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, - onLinkClick: (Link) -> Unit, - onLinkLongClick: (Link) -> Unit, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val totalItems = content.items.size @@ -87,21 +81,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, @@ -110,40 +108,18 @@ fun TimelineItemGalleryView( ) } } - if (content.showCaption) { - Spacer(modifier = Modifier.height(8.dp)) - val caption = if (LocalInspectionMode.current) { - SpannedString(content.caption) - } else { - content.formattedCaption ?: SpannedString(content.caption) - } - CompositionLocalProvider( - LocalContentColor provides ElementTheme.colors.textPrimary, - LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular - ) { - EditorStyledText( - modifier = Modifier - .padding(horizontal = 4.dp) - .widthIn(min = 120.dp), - text = caption, - style = ElementRichTextEditorStyle.textStyle(), - onLinkClickedListener = onLinkClick, - onLinkLongClickedListener = onLinkLongClick, - releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), - ) - } - } } } @Composable private fun SingleItemLayout( + eventId: EventId?, item: GalleryItem, onClick: () -> Unit, onLongClick: (() -> Unit)?, ) { GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -157,6 +133,7 @@ private fun SingleItemLayout( @Composable private fun TwoItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -167,6 +144,7 @@ private fun TwoItemLayout( ) { items.forEachIndexed { index, item -> GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -182,6 +160,7 @@ private fun TwoItemLayout( @Composable private fun ThreeItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -191,6 +170,7 @@ private fun ThreeItemLayout( verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { GalleryItemCell( + eventId = eventId, item = items[0], isLast = false, remainingCount = 0, @@ -206,6 +186,7 @@ private fun ThreeItemLayout( ) { for (it in 1..2) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -222,6 +203,7 @@ private fun ThreeItemLayout( @Composable private fun FourPlusItemLayout( + eventId: EventId?, items: ImmutableList, showOverflow: Boolean, overflowCount: Int, @@ -238,6 +220,7 @@ private fun FourPlusItemLayout( ) { for (it in 0..1) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -260,6 +243,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 +261,7 @@ private fun FourPlusItemLayout( @Composable private fun GalleryItemCell( + eventId: EventId?, item: GalleryItem, isLast: Boolean, remainingCount: Int, @@ -284,6 +269,22 @@ 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.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) { + listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError } + } else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) { + ContentValidationValue.Loading + } else { + mediaContentValidationState + } + } + Box( modifier = modifier .blurHashBackground(item.blurhash, alpha = 0.9f) @@ -305,7 +306,20 @@ private fun GalleryItemCell( VideoOverlay(duration = item.duration) } - if (isLast && remainingCount > 0) { + if (itemContentValidationState.isLoading()) { + CircularProgressIndicator() + } else if (itemContentValidationState.hasError()) { + 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,11 +388,9 @@ internal fun TimelineItemGalleryViewPreview( @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, ) = ElementPreview { TimelineItemGalleryView( + eventId = null, content = content, onGalleryItemClick = {}, onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } 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 0e0a98a96c..eb5c507779 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 @@ -8,20 +8,13 @@ package io.element.android.features.messages.impl.timeline.components.event -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.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.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,118 +24,97 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalInspectionMode 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 -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.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider 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.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive -import io.element.android.wysiwyg.compose.EditorStyledText -import io.element.android.wysiwyg.link.Link -private const val TALL_IMAGE_RATIO_DIVISOR = 3 @Composable fun TimelineItemImageView( content: TimelineItemImageContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, onLongClick: (() -> Unit)?, - onLinkClick: (Link) -> Unit, - onLinkLongClick: (Link) -> Unit, 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 - Column(modifier = modifier) { + Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) { val containerModifier = if (content.showCaption) { Modifier.clip(RoundedCornerShape(10.dp)) } else { Modifier } + + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() TimelineItemAspectRatioBox( - modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), + modifier = containerModifier + .blurHashBackground(content.blurhash, alpha = 0.9f) + .align(Alignment.CenterHorizontally), aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), ) { - 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 = { isLoaded = it is AsyncImagePainter.State.Success }, - ) - } - } - - if (content.showCaption) { - Spacer(modifier = Modifier.height(8.dp)) - val caption = if (LocalInspectionMode.current) { - SpannedString(content.caption) + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } else { - content.formattedCaption ?: SpannedString(content.caption) - } - CompositionLocalProvider( - LocalContentColor provides ElementTheme.colors.textPrimary, - LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular - ) { - val width = content.width ?: 0 - val height = content.height ?: 0 - // if image is narrow and tall use DEFAULT_ASPECT_RATIO - val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) { - DEFAULT_ASPECT_RATIO - } else { - content.aspectRatio ?: DEFAULT_ASPECT_RATIO + 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 -> + val url = content.thumbnailMediaRequestData.source?.safeUrl + if (url != null) { + handleAsyncImageStateChange( + state = state, + onLoaded = { isLoaded = true }, + updateContentValidationState = { contentValidationState.update(url, it) }, + ) + } + }, + ) } - EditorStyledText( - modifier = Modifier - .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout - .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), - text = caption, - style = ElementRichTextEditorStyle.textStyle(), - onLinkClickedListener = onLinkClick, - onLinkLongClickedListener = onLinkLongClick, - releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), - ) } } } @@ -157,9 +129,6 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon onShowContentClick = {}, onContentClick = {}, onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } @@ -172,43 +141,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview { onShowContentClick = {}, onContentClick = {}, onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } -@PreviewsDayNight -@Composable -internal fun TimelineImageWithCaptionRowPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemImageContent( - filename = "image.jpg", - caption = "A long caption that may wrap into several lines", - aspectRatio = 2.5f, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = false, - content = aTimelineItemImageContent( - filename = "image.jpg", - caption = "Image with null aspectRatio", - aspectRatio = null, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } -} - @PreviewsDayNight @Composable internal fun ATimelineItemEventRowPreview() = ElementPreview { @@ -220,9 +155,9 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview { content = aTimelineItemImageContent( filename = "image.jpg", caption = "A long caption that may wrap into several lines", - width = 80, - height = 300, - aspectRatio = 80f / 300f, + width = 40, + height = 20, + aspectRatio = 40f / 20f, ), groupPosition = TimelineItemGroupPosition.Last, ), @@ -235,7 +170,7 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview { filename = "image.jpg", caption = "Narrow image with null aspectRatio", width = 80, - height = 300, + height = 150, aspectRatio = null, ), groupPosition = TimelineItemGroupPosition.Last, 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 104e420d51..76ffda2a15 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter 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.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.ui.media.MediaRequestData +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.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @@ -48,10 +53,14 @@ fun TimelineItemStickerView( onLongClick: (() -> Unit)?, onShowClick: () -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) + + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() Column( - modifier = modifier.semantics { contentDescription = description }, + modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally), ) { TimelineItemAspectRatioBox( modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), @@ -63,11 +72,12 @@ fun TimelineItemStickerView( hideContent = hideMediaContent, onShowClick = onShowClick, ) { - var isLoaded by remember { mutableStateOf(false) } + var isThumbnailLoaded by remember { mutableStateOf(false) } + AsyncImage( modifier = Modifier .fillMaxSize() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier) .then( if (onContentClick != null) { Modifier @@ -91,9 +101,22 @@ fun TimelineItemStickerView( contentScale = ContentScale.Crop, alignment = Alignment.Center, contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, + onState = { state -> + val url = content.preferredMediaSource?.safeUrl + if (url != null) { + handleAsyncImageStateChange( + state = state, + onLoaded = { isThumbnailLoaded = true }, + updateContentValidationState = { contentValidationState.update(url, it) }, + ) + } + }, ) } + + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } } } } 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 0449ef26f0..1a4be5d2e5 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 8d1ef18f39..ce97ecc775 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 @@ -8,22 +8,17 @@ package io.element.android.features.messages.impl.timeline.components.event -import android.text.SpannedString import androidx.compose.foundation.Image 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.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.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.hideFromAccessibility 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 -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.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider 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 @@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.textcomposer.ElementRichTextEditorStyle +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.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive -import io.element.android.wysiwyg.compose.EditorStyledText -import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemVideoView( @@ -75,15 +63,13 @@ fun TimelineItemVideoView( onContentClick: (() -> Unit)?, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, - onLinkClick: (Link) -> Unit, - onLinkLongClick: (Link) -> Unit, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val isTalkbackActive = isTalkbackActive() val a11yLabel = stringResource(CommonStrings.common_video) val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel - Column(modifier = modifier) { + Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) { val containerModifier = if (content.showCaption) { Modifier .padding(top = 6.dp) @@ -91,6 +77,9 @@ fun TimelineItemVideoView( } else { Modifier } + + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() TimelineItemAspectRatioBox( modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), @@ -127,48 +116,35 @@ fun TimelineItemVideoView( contentScale = ContentScale.Crop, alignment = Alignment.Center, contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, + onState = { state -> + val url = content.thumbnailSource?.safeUrl + if (url != null) { + handleAsyncImageStateChange( + state = state, + onLoaded = { isLoaded = true }, + updateContentValidationState = { contentValidationState.update(url, it) }, + ) + } + }, ) - 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() } - ) + 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() } } } - - if (content.showCaption) { - Spacer(modifier = Modifier.height(8.dp)) - val caption = if (LocalInspectionMode.current) { - SpannedString(content.caption) - } else { - content.formattedCaption ?: SpannedString(content.caption) - } - CompositionLocalProvider( - LocalContentColor provides ElementTheme.colors.textPrimary, - LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular, - ) { - val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO - EditorStyledText( - modifier = Modifier - .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout - .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), - text = caption, - onLinkClickedListener = onLinkClick, - onLinkLongClickedListener = onLinkLongClick, - style = ElementRichTextEditorStyle.textStyle(), - releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), - ) - } - } } } @@ -181,9 +157,6 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon onShowContentClick = {}, onContentClick = {}, onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } @@ -196,39 +169,5 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview { onShowContentClick = {}, onContentClick = {}, onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, ) } - -@PreviewsDayNight -@Composable -internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemVideoContent().copy( - filename = "video.mp4", - caption = "A long caption that may wrap into several lines", - aspectRatio = 2.5f, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = false, - content = aTimelineItemVideoContent().copy( - filename = "video.mp4", - caption = "Video with null aspect ratio", - aspectRatio = null, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } -} 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 86e3f1c849..67f1cbfa96 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 @@ -51,6 +51,7 @@ 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.ui.media.contentvalidation.ContentValidationValue 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 @@ -64,6 +65,7 @@ fun TimelineItemVoiceView( content: TimelineItemVoiceContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationValue: ContentValidationValue = ContentValidationValue.Valid, ) { fun playPause() { state.eventSink(VoiceMessageEvent.PlayPause) @@ -103,12 +105,16 @@ fun TimelineItemVoiceView( 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) + if (contentValidationValue.isValid()) { + 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)) 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 a299d2f1ce..cebe7f93b2 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/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt index 9fddfa98ff..3b60a48c78 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt @@ -19,8 +19,6 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider null } +fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (this) { + is TimelineItemEventContentWithAttachment -> formattedCaption + is TimelineItemGalleryContent -> formattedCaption + is TimelineItemAttachmentsContent -> formattedCaption + else -> null +} + fun TimelineItemEventContentWithAttachment.duration(): Duration? { return when (this) { is TimelineItemAudioContent -> duration @@ -123,3 +133,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/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 76502b2975..1d4c7c04ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -135,6 +135,7 @@ fun anAttachmentItem( fileSize: Long? = 1_000_000L, formattedFileSize: String = "1MB", mimeType: String? = null, + mediaSource: MediaSource = MediaSource(url = "", json = ""), thumbnailSource: MediaSource? = null, hasThumbnail: Boolean = false, ) = AttachmentItem( @@ -143,7 +144,7 @@ fun anAttachmentItem( hasThumbnail -> "image/jpeg" else -> "application/$fileExtension" }, - mediaSource = MediaSource(url = "", json = ""), + mediaSource = mediaSource, thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null, fileSize = fileSize, formattedFileSize = formattedFileSize, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt index 58e25e5dcd..e2021e9646 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt @@ -18,8 +18,6 @@ open class TimelineItemFileContentProvider : 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 b2ea1d6bd8..dc70b63f32 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 0000000000..546d37de3d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt @@ -0,0 +1,41 @@ +/* + * 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.ui.media.contentvalidation.ContentValidationValue +import okio.IOException + +fun handleAsyncImageStateChange( + state: AsyncImagePainter.State, + onLoaded: () -> Unit, + updateContentValidationState: ((ContentValidationValue) -> Unit)?, +) { + if (state is AsyncImagePainter.State.Success) { + onLoaded() + } + + 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) + } + } + } + + updateContentValidationState?.invoke(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 61b1fc8d26..0ac8c3be1f 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 499e4bf91a..c164a30160 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 a8be3f3836..3b0794305f 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 @@ -1585,6 +1588,7 @@ class TimelinePresenterTest { featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, + timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1605,6 +1609,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 dc63817e99..6348d9b115 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 d6af8ebd68..11093bd74f 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/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandlerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandlerTest.kt new file mode 100644 index 0000000000..c4861ba57b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandlerTest.kt @@ -0,0 +1,104 @@ +/* + * 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 androidx.compose.ui.graphics.painter.Painter +import coil3.compose.AsyncImagePainter +import coil3.request.ErrorResult +import coil3.request.SuccessResult +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason +import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason.MCS_MEDIA_NOT_CLEAN +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.mockk.mockk +import org.junit.Test +import java.io.FileNotFoundException + +class AsyncImageStateHandlerTest { + @Test + fun `test Empty state sets the content validation state to Unknown`() { + val updateContentValidationState = lambdaRecorder {} + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Empty, + onLoaded = {}, + updateContentValidationState = updateContentValidationState, + ) + + updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown)) + } + + @Test + fun `test Loading state sets the content validation state to Loading`() { + val updateContentValidationState = lambdaRecorder {} + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Loading(null), + onLoaded = {}, + updateContentValidationState = updateContentValidationState, + ) + + updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Loading)) + } + + @Test + fun `test Success state sets the content validation state to Valid and triggers onLoaded callback`() { + val updateContentValidationState = lambdaRecorder {} + val onLoaded = lambdaRecorder {} + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Success(mockk(), SuccessResult(mockk(), mockk())), + onLoaded = onLoaded, + updateContentValidationState = updateContentValidationState, + ) + + onLoaded.assertions().isCalledOnce() + updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Valid)) + } + + @Test + fun `test Failure state sets the content validation state to Invalid if the error matches`() { + val updateContentValidationState = lambdaRecorder {} + val onLoaded = lambdaRecorder {} + val mediaNotCleanError = ClientException.ContentScanner("dangerous", MCS_MEDIA_NOT_CLEAN) + val forbiddenMimeTypeError = ClientException.ContentScanner("dangerous", ContentScannerErrorReason.MCS_MIME_TYPE_FORBIDDEN) + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), mediaNotCleanError)), + onLoaded = onLoaded, + updateContentValidationState = updateContentValidationState, + ) + + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), forbiddenMimeTypeError)), + onLoaded = onLoaded, + updateContentValidationState = updateContentValidationState, + ) + + onLoaded.assertions().isNeverCalled() + updateContentValidationState.assertions() + .isCalledExactly(2) + .withSequence( + listOf(value(ContentValidationValue.Invalid)), + listOf(value(ContentValidationValue.Invalid)) + ) + } + + @Test + fun `test Failure state sets the content validation state to Uknown if the error does not match a content scanner one`() { + val updateContentValidationState = lambdaRecorder {} + val onLoaded = lambdaRecorder {} + val error = FileNotFoundException("File not found") + handleAsyncImageStateChange( + state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), error)), + onLoaded = onLoaded, + updateContentValidationState = updateContentValidationState, + ) + + onLoaded.assertions().isNeverCalled() + updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown)) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt index 14fd284067..576ab07532 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -29,21 +29,32 @@ fun EqualWidthColumn( ) { SubcomposeLayout(modifier = modifier) { constraints -> val measurables = subcompose(0, content).map { it.measure(constraints) } - val maxWidth = measurables.maxOf { it.width } - val newConstraints = constraints.copy(minWidth = maxWidth) - val newMeasurables = if (measurables.all { it.width == maxWidth }) { - // Skip re-measuring if all children have the same width - measurables + if (measurables.isEmpty()) { + // No children, so just return a layout with 0 width and height + layout(0, 0) {} + } else if (measurables.size == 1) { + // No need to re-measure if there's only one child + val measurable = measurables.first() + layout(measurable.width, measurable.height) { + measurable.placeRelative(0, 0) + } } else { - // Re-measure with the largest width as the minWidth to have all children constrained to the same width - subcompose(1, content).map { it.measure(newConstraints) } - } - val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() - layout(maxWidth, totalHeight) { - var yPosition = 0 - newMeasurables.forEach { measurable -> - measurable.placeRelative(0, yPosition) - yPosition += measurable.height + spacing.roundToPx() + val maxWidth = measurables.maxOf { it.width } + val newConstraints = constraints.copy(minWidth = maxWidth) + val newMeasurables = if (measurables.all { it.width == maxWidth }) { + // Skip re-measuring if all children have the same width + measurables + } else { + // Re-measure with the largest width as the minWidth to have all children constrained to the same width + subcompose(1, content).map { it.measure(newConstraints) } + } + val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() + layout(maxWidth, totalHeight) { + var yPosition = 0 + newMeasurables.forEach { measurable -> + measurable.placeRelative(0, yPosition) + yPosition += measurable.height + spacing.roundToPx() + } } } } 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 f323b316f7..b1501601d9 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 fdc538ec44..2f61b8f58a 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 713057ecda..d31b5671eb 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/ContentErrorView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt new file mode 100644 index 0000000000..85753246d3 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt @@ -0,0 +1,93 @@ +/* + * 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.foundation.layout.size +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.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 + +@Composable +internal fun ContentErrorView( + title: String, + message: String, + 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( + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Error(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary + ) + Column(Modifier.weight(1f)) { + Text( + text = title, + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyMdMedium, + ) + val textMeasurer = rememberTextMeasurer() + val textContent = remember(message) { + movableContentOf( + @Composable { + Text( + text = message, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + ) + } + + // 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(message) { + updatedOnTextLayout?.invoke(textMeasurer.measure(message, overflow = TextOverflow.Visible, constraints = constraints)) + } + + textContent() + } + } else { + textContent() + } + } + } +} 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 0000000000..c0afa69d1a --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt @@ -0,0 +1,197 @@ +/* + * 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.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update + +/** + * Wrapper for the content validation state of an event. + * + * It can be used to check the overall validation state of the event, as well as the validation state of each media item in it. + */ +@Immutable +interface ContentValidationState { + /** + * A flow that emits the overall validation state of the event, which is an aggregation of the different media values. + */ + val overallStateFlow: Flow + + /** + * Returns a flow that emits the validation state of a specific media item, identified by its URL. + */ + fun getMediaStateFlow(url: String): Flow + + /** + * Returns the current overall validation state of the event. + */ + fun getCurrentOverallState(): ContentValidationValue + + /** + * Returns the current validation state of a specific media item, identified by its URL. + */ + fun getCurrentMediaState(url: String): ContentValidationValue + + /** + * Updates the validation state of a specific media item, identified by its URL, with a new [ContentValidationValue]. + */ + fun update(url: String, newValue: ContentValidationValue) +} + +/** + * A no-op implementation of [ContentValidationState] that always returns a fixed validation state. + * By default, this is [ContentValidationValue.Valid]. + */ +@Immutable +class NoopContentValidationState( + private val initial: ContentValidationValue = ContentValidationValue.Valid, +) : ContentValidationState { + override val overallStateFlow: Flow = MutableStateFlow(initial) + override fun getMediaStateFlow(url: String): Flow = MutableStateFlow(initial) + override fun getCurrentOverallState(): ContentValidationValue = initial + override fun getCurrentMediaState(url: String): ContentValidationValue = initial + override fun update(url: String, newValue: ContentValidationValue) {} +} + +/** + * A default implementation of [ContentValidationState] that tracks the validation state of media items in a mutable map. + * It calculates the overall validation state based on the individual media states. + */ +@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.map { 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 + } + + override fun update(url: String, newValue: ContentValidationValue) { + states.update { current -> + if (current[url]?.isValidated() == true && newValue.isLoading()) { + // If the current state is already validated and the new value is loading, we keep the current validated state + current + } else { + // Otherwise, we update the state for the given URL with the new value + current + (url to newValue) + } + } + } +} + +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.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError } + states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid + else -> ContentValidationValue.Unknown + } +} + +/** + * Represents the validation state of a media item or an event. + */ +@Immutable +sealed interface ContentValidationValue { + /** Indicates that the content is valid. */ + data object Valid : ContentValidationValue + + /** Indicates that the content is invalid/unsafe. */ + data object Invalid : ContentValidationValue + + /** Indicates that the content is currently being validated. */ + data object Loading : ContentValidationValue + + /** Indicates that an unrecoverable error occurred during validation, and it should not be retried. */ + data class UnrecoverableError(val error: Throwable) : ContentValidationValue + + /** Indicates that the validation state is unknown and should be checked. Recoverable errors are also represented with this. */ + data object Unknown : ContentValidationValue + + /** + * Returns true if the validation state is either [Valid], [Invalid], or [UnrecoverableError], meaning that the validation process has completed + * and a definitive result is available. + */ + fun isValidated(): Boolean = when (this) { + is Valid -> true + is Invalid -> true + is Loading -> false + is UnrecoverableError -> true + is Unknown -> false + } + + /** Returns true if the validation state is [Valid]. */ + fun isValid(): Boolean = this is Valid + + /** Returns true if the validation state is [Invalid]. */ + fun isInvalid(): Boolean = this is Invalid + + /** Returns true if the validation state is [Loading]. */ + fun isLoading(): Boolean = this is Loading + + /** Returns true if the validation state is [UnrecoverableError]. */ + fun hasUnrecoverableError(): Boolean = this is UnrecoverableError + + /** Returns true if the validation state is either [Invalid] or [UnrecoverableError]. */ + fun hasError(): Boolean = this is Invalid || this is UnrecoverableError +} + +/** + * Collects the overall validation state of the [ContentValidationState] as a [State] in a Composable context. + */ +@Composable +fun ContentValidationState.collectOverallState(): State { + return produceState(getCurrentOverallState()) { + overallStateFlow.collect { value = it } + } +} + +/** + * Collects the validation state of a specific media item, identified by its URL, as a [State] in a Composable context. + */ +@Composable +fun ContentValidationState.collectMediaState(url: String?): State { + if (url == null) { + return remember { mutableStateOf(ContentValidationValue.Unknown) } + } + + return produceState(getCurrentMediaState(url), key1 = 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 0000000000..df7737e5fb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt @@ -0,0 +1,20 @@ +/* + * 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 { + /** + * Returns the [ContentValidationState] for the given [eventId]. If none exists, it creates a new one with [ContentValidationValue.Unknown]. + */ + 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 0000000000..999558898a --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.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.libraries.matrix.ui.media.contentvalidation + +import io.element.android.libraries.matrix.api.core.EventId + +/** + * In-memory variant of [EventContentValidationCache] that holds the validation state of event contents, based on their [EventId]. + * + * Note: this is intended to be used in tests. + */ +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 0000000000..b1e4ffc02a --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt @@ -0,0 +1,46 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +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, +) { + ContentErrorView( + title = stringResource(CommonStrings.content_scanner_unsafe_title), + message = stringResource(CommonStrings.content_scanner_unsafe_message), + modifier = modifier, + contentPadding = contentPadding, + onTextLayout = onTextLayout, + ) +} + +@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 0000000000..ab7f6da042 --- /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/media/contentvalidation/NotFoundContentView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/NotFoundContentView.kt new file mode 100644 index 0000000000..019465965c --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/NotFoundContentView.kt @@ -0,0 +1,46 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +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 can't be fetched. + */ +@Composable +fun NotFoundContentView( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues.Zero, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + ContentErrorView( + title = stringResource(CommonStrings.content_scanner_not_found_title), + message = stringResource(CommonStrings.content_scanner_not_found), + modifier = modifier, + contentPadding = contentPadding, + onTextLayout = onTextLayout, + ) +} + +@PreviewsDayNight +@Composable +internal fun NotFoundContentViewPreview() = ElementPreview { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + NotFoundContentView() + } +} 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 3179c748ea..2105655b01 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 4d0e02aa6f..7ff41abcd3 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,9 @@ 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.media.contentvalidation.NotFoundContentView 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 +59,22 @@ 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() + is ContentValidationValue.UnrecoverableError -> ReplyToNotFoundContent() + else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) @@ -86,7 +94,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 +221,50 @@ private fun ReplyToContentText( } } +@Composable +private fun ReplyToInvalidContent( + modifier: Modifier = Modifier +) { + InvalidContentView( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp), + onTextLayout = null, + ) +} + +@Composable +private fun ReplyToNotFoundContent( + modifier: Modifier = Modifier +) { + NotFoundContentView( + 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)) + } +} + +@PreviewsDayNight +@Composable +internal fun ReplyToNotFoundContentPreview() { + ElementPreview { + ReplyToNotFoundContent(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 3a2f2321db..fb60a7488c 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 f6de6cf839..7b9f65e60e 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 10858c76c8..4cbcd75a5e 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 05d5dd4022..f489186ade 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 611d619d52..957f0011d3 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 7d9e634687..3aad3f1061 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 90708db802..5a0b56a9b7 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 be73787828..96059cfd1f 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 2dfb11a89f..d254985ebf 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 6809f6b685..f347e8181f 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 9cd134b843..0c2e36903c 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 7d0018f120..7147e74e72 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 88aefb842c..a5151f3b68 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 710aaff1ab..670dfd6242 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 f917e8e0d1..b25b8577ee 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 a869690907..ad44d1b372 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 039f516547..9b2260d441 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,7 @@ 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.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,8 +37,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 +139,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 contentHasErrors = currentValidationState.hasError() + + Box( modifier - .clip(RoundedCornerShape(6.dp)) - .background(ElementTheme.colors.bgCanvasDefault) - .border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) + .clip(shape) + .background( + color = if (contentHasErrors) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault, + shape = shape, + ) + .border( + width = 1.dp, + color = if (contentHasErrors) 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 (contentHasErrors) 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/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistArchitectureTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistArchitectureTest.kt index fe303f360c..c0e62482fa 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistArchitectureTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistArchitectureTest.kt @@ -34,6 +34,7 @@ class KonsistArchitectureTest { .classes() .withNameEndingWith("State") .withoutName( + "NoopContentValidationState", "CameraPositionState", "CustomSheetState", ) 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 8a088e8e93..97def07130 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 @@ -145,7 +145,8 @@ class KonsistPreviewTest { "TextComposerVoiceNotEncryptedPreview", "TextComposerVoicePreview", "TextFieldDialogWithErrorPreview", - "TimelineImageWithCaptionRowPreview", + "TimelineItemAttachmentsViewScanningContentFailedPreview", + "TimelineItemAudioViewScanningContentPreview", "TimelineItemEventRowForDirectRoomPreview", "TimelineItemEventRowShieldPreview", "TimelineItemEventRowTimestampPreview", @@ -155,13 +156,21 @@ class KonsistPreviewTest { "TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithThreadSummaryPreview", + "TimelineItemFileViewScanningContentPreview", + "TimelineItemGalleryViewScanningContentFailedPreview", "TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemImageViewHideMediaContentPreview", + "TimelineItemImageViewScanningContentPreview", "TimelineItemRedactedMessagesGroupPreview", + "TimelineItemScanningContentFailedPreview", + "TimelineItemScanningContentWithInvalidRepliesPreview", + "TimelineItemScanningContentWithRepliesFailedPreview", + "TimelineItemStickerViewScanningContentPreview", "TimelineItemVideoViewHideMediaContentPreview", + "TimelineItemVideoViewScanningContentPreview", + "TimelineItemVoiceViewScanningContentPreview", "TimelineItemVoiceViewUnifiedPreview", - "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", "TimelineViewWithReadMarkerBothIndicatorsPreview", "TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview", diff --git a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt index e9bd34d10e..971783cd63 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.customreaction_EmojiItemWithPopup_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png index 912b90b537..a2b35f18ca 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384 -size 5325 +oid sha256:c5572491b675a16724cd9e33ebb477c82edf285fa07d57ebef09c670fcaaddf1 +size 21285 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png index 45011fea4d..9c425cec6e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b -size 20793 +oid sha256:290a2be6838445739b0181006f9fea87337a85cfed3573c153e9f503c66624a6 +size 4994 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Day_0_en.png index 08169845d5..12bd190dd1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13 -size 295298 +oid sha256:ce15ca9f89274620728715e3448ce671e2253f4fed22a44035460e9a3709ca0b +size 647404 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png index 3f12875a5b..0ccdcbb603 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031 -size 294992 +oid sha256:d0424685437a433b35b589668b409d55f26ab41bcae01af0121a54033c56a8c2 +size 646194 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Day_0_en.png deleted file mode 100644 index 29e7582971..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f -size 612494 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png deleted file mode 100644 index 176ebfa36b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3 -size 611488 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png index aa00f45355..7577f6934f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa4034a06aa6e8d24806b640d9c6604bf4cffc5ec5664d2b9b189e6c39850ce0 -size 42543 +oid sha256:9e8d673869b3fd1d6be2b95c79c8a12e94732f74f28b51a38e40c166a9eae4f6 +size 39466 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png index 29714c40b3..6db39b19c9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b777363de123a6de81360e4ee9c5066c8953ac103c52349979e4c3fdca1e20c -size 41699 +oid sha256:99672a8ee0d4d22f0d15a74d4e3c8923b762116ecd2e741fd09c184963737e11 +size 38609 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_3_en.png deleted file mode 100644 index faf7f18e65..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c43d85e4658b9fb930ef76258479a876d5cde3b545bc4f4a76d86e4ef2d7a82f -size 10773 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_4_en.png deleted file mode 100644 index f8f7b243b3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c836c0a00fb5697138349028540d5e1cb4f1fddd2fd50d388cb5a1b55ccaa8f4 -size 21023 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_3_en.png deleted file mode 100644 index f5db366e09..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9fa672f2cb7dbf0fff171d507e66d1ed8494cbcd93b48bef1ee1b9b75d6717e7 -size 10499 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_4_en.png deleted file mode 100644 index 2ed11e26e2..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5735813e0f9c40e5d861ba0c35ba73689ff7ee41224829289ce017746323922c -size 20150 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_3_en.png deleted file mode 100644 index c65619f05d..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:10b79226a4eb5c67afc1fc8ace8c6fb49429a69b1e1518b622dcfd20bc2820ea -size 10032 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_4_en.png deleted file mode 100644 index d68ac1725c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:085310083c4150fb70e48087881e529f17005df69cc01dd53807a06266f4f4bb -size 20317 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_3_en.png deleted file mode 100644 index 48242a7b75..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4ef1884c4cac3debea52affbab071110a49b40cebfd38b316693085464fcc440 -size 9859 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_4_en.png deleted file mode 100644 index 3146070439..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:983620627af06dc1937b040fe21b3e12920e84116cc584fe76a7ef799473540c -size 19547 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png index b06744a137..1cedae7a21 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a649c1213f7577e41c8fb847694a433fb8296008b020ce9c11b9f19e4daf2362 -size 366552 +oid sha256:3055caaede7c8a66712d4b91f82924e8ce360aba73c65b0174eaf5c881182ed6 +size 362018 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png index 4d6c2f9914..0bc2983ce1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a45bc7d128c477a017d3f64a1e7ae14b4de888873fce20e5b8f146871f52d08c -size 372020 +oid sha256:0141d829cc2e45571ab9d83b4bb186d4ec98c8d190bf133ff81846b94208e029 +size 367771 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png index 5bf7ad4ca9..0bc2983ce1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f46ed316ce20fdec7c2a8ab834cc68c0db8f1cf5dab05a3835133274bdf74968 -size 372000 +oid sha256:0141d829cc2e45571ab9d83b4bb186d4ec98c8d190bf133ff81846b94208e029 +size 367771 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png index 14393b4454..6182255ade 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1cb42c166ced625581718e7f0f11fe9a6a04a0fc9ccd907addd368c44884069 -size 309093 +oid sha256:2515f92e88a09887126a4c479a9ce803d8ead7e6d01b1bee196b0354e06df836 +size 305941 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png index 36cd635ba6..2fa6580120 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4a12a0fc4ad8f9d5b64a30e3d7ba8c678dfd8582c98b668d292594dba7b2742 -size 361947 +oid sha256:960a42eae929b92bfe78c8efa697a42f05457ec3ec8ef7fedfc97d09187a7795 +size 357766 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png index d2eafbba35..50c642af70 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e813d8530f1083a960a30cda348e7b68ec54b02f62773fd83788054d9b78a8c -size 371473 +oid sha256:6dd15a5434b1282d154bf10b74e8b4cab4671845f5e1a6111d86629a554f93bd +size 367628 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png index c4f9b5ac91..50c642af70 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f8c6615f0b06c1e1a358622e0598aa89bc7c7e2b5bdf7daad269c92434e124d -size 371473 +oid sha256:6dd15a5434b1282d154bf10b74e8b4cab4671845f5e1a6111d86629a554f93bd +size 367628 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png index 146e3a9211..cf02780154 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c144e259fa322bd899a047ade06c7105a6d5f41732193baa9e1437067d7b47f -size 302873 +oid sha256:460ad18856a9b9afefb8e8d8544f7362396a3755d0d51622c1449f2b4f62812d +size 299907 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_4_en.png new file mode 100644 index 0000000000..9238837c39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_4_en.png new file mode 100644 index 0000000000..9238837c39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png deleted file mode 100644 index f792a1663a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399 -size 607806 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png deleted file mode 100644 index 18319e718f..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74 -size 606823 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Day_0_en.png new file mode 100644 index 0000000000..04c37256d4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e801fb628e90136e1a2c83c2ec8e9ad9b75febd0fb0566a3c66aa13f42799912 +size 48113 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Night_0_en.png new file mode 100644 index 0000000000..5925416c47 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAttachmentsViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:accb6fd85427c220b02c271316568e09f00e7307c861d75bdcd43456c7c9d892 +size 47680 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..4cefab49f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86e1ca5c57fe2522dde2ac9037b1eb808cd2afff4e83bbc6def1c5cec6eb1208 +size 16510 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..48b2f2b047 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemAudioViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad421c54c0bd3e25ebf07d9a005cb050ed62fef9c19e1a5c01f54f0f3cf2931b +size 16818 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png index ab6392a424..0435f55de6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d -size 379505 +oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364 +size 379511 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Night_0_en.png index 9cbd5c1d95..f0b5506b89 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0e170b4bea262de612bf3374d475fcd25470ff91b7554d20809c51857a78cec -size 377963 +oid sha256:04ea6c2947a21d3d4a3d43e9c4750c4f2a49056536a4da3f34c95df8353e9de3 +size 377990 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png index 23ce668685..632aee5c4e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:620849b893491d33f5b0a2a0f2d0814c40d111026aee52e299b893909b08afb8 -size 48309 +oid sha256:0782326696849e56e4fde44a2cd36197f3d1d3e08ca1f942f2ed00a4b41da400 +size 48247 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png index 7521205f85..a736afce19 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50ee16d8894cb50860bbe0dafb7f1e6002ba18336070875699409c1ffd1b7de0 -size 103322 +oid sha256:9f84c2d5963f9fc81c10f53f336c2edc1ed5b176b9c294ef22548ec17c3e0fab +size 103104 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png index 829dabebd5..ab7e249fae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7381b146b64c1281d8e3032539c38270b086d2173d635d969dce3e1f24e07081 -size 76539 +oid sha256:df2867676c67b8c9db0a96de915886444242313ae1333a4a1fcc6a5d11a39050 +size 76255 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png index dee2a3b025..8657eb4117 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d737d3c634a812cd2338be8c372de94a5f0a959e56be9b5acd6a4a2d83b57879 -size 39262 +oid sha256:41d765ad4383ab40232716a22e202fc8a2fcd59b9a3a44f417631f4d880806b4 +size 38890 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png index 8857763fe8..a11cab2edc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a89c2d2ece84f2bd9a4613c84ee7a4090cd00e79de88bbdb0dc3b82f6f867b1e -size 47837 +oid sha256:25b3cbef7e304a3135bd8a49af18465fa79f35ebfdcf4de867163874b31887e8 +size 47739 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png index ac841eead4..81218bc9e5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e2cccbfba9adf2c1aa799e98410f32fc3895a28552577ae5a49860cd04e9d9e -size 102247 +oid sha256:e6605daf6c22d2461e82df7e37bd25a70e5c2728047fc4dd971107f977c44525 +size 102080 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png index b6c5b1a006..baf457824d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b98b6d69d157f84bae3dcd9f8cfba2f98d1f8dbd923b9d8ec803dffa18f7d92a -size 75860 +oid sha256:2b7f7fa2ac9f05d539033679c63e5ea242d4b943ac3259371b4591757ea1c68b +size 75647 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png index 14597ddb08..c4d02de93e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9da51e0ce86a07b09548161e60b0bf76cae5dc7fa53929ea23f81f416347de6d -size 38627 +oid sha256:2ff7894525ac8a1582baec5a5466912dfc32a627e58ed969b7acb881fcfee087 +size 38346 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png index 3ac59caeea..1f0fdabe6d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4 -size 365082 +oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551 +size 365073 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en.png index 86018764ac..195aa04fe2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8344427a0fe87cbf0883ff53a9ee4dcf90976a7011f6618d85961df48b2c53a9 -size 370228 +oid sha256:76e5ae44362792d570cd82f3910c8bd0acde509b8418c5892e0385e7d636f042 +size 370223 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png index 29771235f0..058a3992c3 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60b9f2b920cd2a2a414805d1f0f77925232d9ae60bd607fa18d4395cda2dd73d -size 363291 +oid sha256:00fe1e676086766a3eac3e4457eff52c989789fc5d8d0df2624c019ed25ce9af +size 363322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en.png index c6d4541184..db4ea8296a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c00e504593d8eb61b598f05f46ee4d591757975a80c0b4227b240847d2cd4868 -size 368510 +oid sha256:2f0d24b8011564ee7a8b2f8c2e6a24eda668ee42d451d862f884acb7926aa06c +size 368529 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png index 94428abb56..84d9d4e5c3 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87 -size 347897 +oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e +size 347890 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png index 7ddac02717..73b7e674e2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4ac79f3b8fb4e231244bd96b244a4f2526d6ed28d558f9d50734d83bbb74992 -size 363909 +oid sha256:991deacdfb7bc371a2df960a2364ae3b2187e588aa5afe7f39409e367040dd9c +size 363893 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png index 22ff4965d1..bac23aab98 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:787151f8f242a8794a804bf14831f6ca680814d617e85feb3652103e8a0f20cd -size 346565 +oid sha256:e30686c7c037ef102717e0316543faf4d3ed2671c21eca96a667150daab380d6 +size 346614 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png index 4e17de6908..b6e4650d9d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1e15400fa2c5ee6087316501445c551a3932b65e16de31e68acc5efbd16a559 -size 361603 +oid sha256:2cd357a1fbaa425eca95587bff2f768a45e773f983f75664875653ba1900e534 +size 361625 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png index 67490ba700..01d4dca84b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4431e2d9f30123949ea5676ce60f642234adcf9b5a33f2acc3f80ebf6609a66 -size 369730 +oid sha256:ef2bf449671b652cb152b3baab92ab100eab77a54229ab49daeab156f2f484d0 +size 369724 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png index 1600afff71..f0dfbc3321 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:273351cc9c533b2424859a0de00a8a3786628abc3a88175705c4205dedf7e9aa -size 354737 +oid sha256:f7a6302320bd1e4258e03487539bf46b0b12781dfc8a22b3f9f267017a6146fa +size 354751 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_11_en.png index e38cf03ad6..c4943b892c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41a0e404dea7f52359b24b3ec3926b1405429b165cfed44c63b3922ea6d3cb46 -size 368114 +oid sha256:4c410e853089cc2c4a2c48a00dd9a8babc8095aed72bb4625057444710559c6c +size 368129 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_1_en.png index 68f8148879..b5dac8434c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ca8cf82aa8ab1bdad56a27584b27789402aeb7ad8fadd038280a376fb75a72c -size 345094 +oid sha256:1a92f8af94682facac66d4aacb6e49f345561c15be2f8b689041b78aeaaae831 +size 345062 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png index 1b99468924..8aa2cca147 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a9509411777f342261cab58b92a022b3c9f8f0f7a9e4d0527120a91976575f0 -size 357733 +oid sha256:02ecb903124273e24151ea2088f6b99144f7dcadba00a582b6210dd23835b5c9 +size 357743 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_3_en.png index b5032c29b3..cececc229a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dce23463c335d01eb67293cfcede5d85e96ab2ba035cc9a188d42e8e6866bc63 -size 356831 +oid sha256:f0c6dafaa41849af00d5097a85bd88e3a054a0a9e41c0466a08e6ff8dd6102b2 +size 356857 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png index 14a40a5676..a332744358 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a11044fe90227f1f316a1ade8587e545bb620e11cca76865fc2eb83cdb817fc5 -size 364655 +oid sha256:588967869cbd25ed8b82aff5618175660a8a9e45a5a861b1c7867cf1913f575b +size 364646 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_5_en.png index 133fd3903f..d7b969fd5b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:426d1c844a8c88281c5fa4c61916d93278fdfd38f8cdf2dc54bf1a821f2de3eb -size 397167 +oid sha256:2528df873713755f542f61cd794080929586c1bd5f316c0378ce9e837c21d50b +size 397145 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png index 63bd9c3f6d..0041a870b9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6cd94664f89bdad2a9761ea8b20acb83fed3ed7cb5be41af8fe4852643c0bfc -size 356175 +oid sha256:a2bd77c08be705c66225f16c29fd6826decd92b41922d0f8ffc0d1a63d8c59d2 +size 356193 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_7_en.png index f67906a37c..9fcf389708 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffa68c8398d789444151855496e5df4ffa03218a383db6ea46fd4f979829b823 -size 355831 +oid sha256:9daeee1aaa04247da3c8bb5aaae9947aab1ed2d2057995e53d6ebdb63e5cd356 +size 355848 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png index d235670def..6e83d6491d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a00bd766a00b7e7c6615fc53bf469b3ca5056a6c514c096431ae38d99d604306 -size 364845 +oid sha256:34152c742abc486a48234e22101833bc163cf11979890d06aac41e68e3c404d0 +size 364846 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_9_en.png index b10f9741b9..0021619c44 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ec1b0244dab22414c6719b3309426e13128d0c70fb67dc66cd8a60d42521fd0 -size 355411 +oid sha256:7b23d3a37774283067a8919b8c332b1bb5a79e90dceb7216b484b985491282e5 +size 355389 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png index 422c324695..77abe20982 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1df8d22766981fc76d6d767dc3f40d98cadc7c51be20417b067b91af604232b -size 367884 +oid sha256:5c3d87862c6d7fc88d0f23cd54c5b9f77028992fdd7c282a195b964dd17e85ac +size 367855 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png index 448bcec677..1d34761a9b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe0daf9161bc2da194992a2fc9505bf2009c5dbed78cdb35d2eb7ed1380a7df5 -size 353037 +oid sha256:f7d9b5fc0ff5d7828ad6f4dc0c9bd9520b4920553da97ed7b4e030ee844c0208 +size 353054 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_11_en.png index 5243218ae3..c081fa16a5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a85db2f5958db867578663533f204b2e803372d470da890fd8056ffed0cf46c -size 366254 +oid sha256:6a92bcf5885322e11a706071969fb674b1f6e60e021af153aaa125baa4a602ae +size 366285 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_1_en.png index b6d1f07279..47a9a6b630 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bee12a6bdb18fea5ac6997671bfcb903296da7278cf69b2456330066ca31dfb -size 342870 +oid sha256:54372845e922d047408dd960d048b9b5eae2263d589117d8e7d197741aa34f32 +size 342827 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png index 5560c21967..ceca184bd7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af4a48ee80535f2c0a226c4ffa4344054fb63b7b0a88ea2c030a4eb8dd5e9644 -size 356078 +oid sha256:8e82011a72f36889c53ab61ed2b9947c7fb5f868c9b7010904fc10f4ca587082 +size 356082 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_3_en.png index 5cdad1df92..5cc5cfe4fc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e2d8caf5aaae7d3406c703288e491f328552f0acd18e56afd5265b4ceaf1cd1 -size 355272 +oid sha256:6524cc87257cc9d3f135904f4e14594b56df695e71fe576418d2319c472d710b +size 355285 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png index e9d85990e8..a431a9429a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d4b6ea4a697735bbb0fac936b89cde99c5077973ba828894bf50b5dae233b3 -size 362848 +oid sha256:93c2df04d65f6078ce3f7067fbae8a7317ec42ed0fc013dee962a3953269f3b7 +size 362813 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_5_en.png index 0eb106f4a9..b51a89586b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:217ac95bf476c53d2dc6d3d0143b21312bf38b8ab86c8dfd4f9360582b584f9d -size 395476 +oid sha256:e642229c58169775fe702769a306bbef22698516ae6f7ac75a761213c3f195fa +size 395446 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png index aa322fc2e4..ae8495618a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5fae87094ea13b14e2dc7c16ca96e4f8096ff39e962799468fb4ead4c666620f -size 354381 +oid sha256:08057fdc1e1184720b438ddd2a8753f00791baf2456cc30b45de8e6bdeb57e8e +size 354404 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_7_en.png index 9d617f6688..40105c1498 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dabad9dcd5b8f0d4bb964a0187ad7c94b023b04552dacd29a077daddfd48d550 -size 354052 +oid sha256:5ed24d190f10c079f8cf176404372d40eb25cc78ce595a22febc540315057b86 +size 354075 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png index 0cfd1dc985..1a8ea3e4e9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:545c9e8933e420cc3f8fbba0fbd5162c946c65a20721faeb77c568596ca720cd -size 363045 +oid sha256:66aba7b4271a990fac85b0bbc7cbdc371fa1e38a4da5bb4e2f62a80b21cb3a8f +size 363012 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_9_en.png index c8306263a0..c9227ebf94 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:756f0cff90ddd61adce2f467c0384d74d430f6d51c091e4980e45bcc9ee6f0fd -size 353705 +oid sha256:e614f4a8657637c584a283d69124236d00fe1251c2b8d739c5688a57a6fd152e +size 353693 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemFileViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemFileViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..ddbd585aa4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemFileViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemFileViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..3584a58631 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemGalleryViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemGalleryViewScanningContentFailed_Day_0_en.png new file mode 100644 index 0000000000..3618ebc14e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemGalleryViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59fe552df2e412f3850c248c9837e789e6b9cae2b659d3d9e53fb5abef187a16 +size 164976 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemGalleryViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemGalleryViewScanningContentFailed_Night_0_en.png new file mode 100644 index 0000000000..a6531edce0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemGalleryViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7942cb8112895b177dcef36a2ad9824bd5b380a6442f8bfd6d1bdf2f88b8b95 +size 164488 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemImageViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemImageViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..930a0ecf82 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemImageViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemImageViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..e52ec14d2e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentFailed_Day_0_en.png new file mode 100644 index 0000000000..282f372040 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9252417e89d51f89041e42a43a6685a8278832fa976b85bdc024e6cc5281857 +size 37983 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentFailed_Night_0_en.png new file mode 100644 index 0000000000..c065368538 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40441524640785b0a1ec20fef0c083fbd69105f48142e30a05ca7e1eae4b6c1e +size 38889 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Day_0_en.png new file mode 100644 index 0000000000..8f65bb19fd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:020f44e2c54b1449ea4b4f3608073433133ebb3d3cae3be20dab121a103d2f65 +size 66749 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Night_0_en.png new file mode 100644 index 0000000000..cc10efcb15 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithInvalidReplies_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75b14fa771f05ebd3ab6a6d57068c59259ac6a7c3891e9cbdb96d4a025e6491a +size 66992 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Day_0_en.png new file mode 100644 index 0000000000..6a78894774 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d56fc7f2007ec4679281452b42591c0e125bcb6efe15739fd1d1d3709a5d4a2 +size 53534 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Night_0_en.png new file mode 100644 index 0000000000..a834e8e7d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemScanningContentWithRepliesFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3fc62d83354b76e3844818a63a97cac0afbf04a2992f8acb150c1976003e057 +size 54679 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..c867853a06 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09774478b8279b1603e5669eb20ff0eaffbcb055fa6c650f0a9beb15a0576c94 +size 146145 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..e87bb6e271 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemStickerViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b28e20d5b38ef952a949c1dd464d7eca42dd3d9b39e5afd6a91ad36e188676c +size 145737 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVideoViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVideoViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..b683d83582 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemVideoViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVideoViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..34d69603dc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_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_TimelineItemVoiceViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVoiceViewScanningContent_Day_0_en.png new file mode 100644 index 0000000000..2bfb17b0e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVoiceViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9088ebdc55620f1829aa78d2b46aaa57413ed6925fdbf5c6f60bb2a1d7e4a062 +size 14265 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVoiceViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVoiceViewScanningContent_Night_0_en.png new file mode 100644 index 0000000000..ac4ac1435f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemVoiceViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:158974ba9eae0d43ac7a75702254dca08a081999a3f89dd1a53152aeb02636b0 +size 14739 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 0000000000..5fc8a42dda --- /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:cdd3a8df3a9decf6e84d8deb8bbca28f844dc812a238d89f34239e546df9635e +size 13015 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 0000000000..6223b491cd --- /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:e475efcf0d4e56d6519007e6b6e7d671f4bb8c283c2b69336877aac3540eb284 +size 12679 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Day_0_en.png new file mode 100644 index 0000000000..db17a4bb83 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0a59033bbc70903db2251e44c92248a3d5a574a4fe0a5347f74b4ba0b065fee +size 10002 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Night_0_en.png new file mode 100644 index 0000000000..49914a6484 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_NotFoundContentView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d963d8b910b34a552636f4bf9a5657acef2a1507469acf136074a88cf3ff7c4 +size 9819 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 0000000000..7a941824b1 --- /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:779d234f352bcd163ad792b858dc832c0e01c0598766c5cd636e2767ce10b30f +size 13153 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 0000000000..b79f27aaae --- /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:12a4056aeda90fbe1d54cc6d5fb7c01b54c5c97eb9aab2d49b665da49860f3e3 +size 12540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Day_0_en.png new file mode 100644 index 0000000000..a4e8be33e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0d67a139c137d80f642f7a00b16c1e3ac916f7b82cd780cfe5ca93759ec8e96 +size 10170 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Night_0_en.png new file mode 100644 index 0000000000..616e0cf151 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToNotFoundContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27303f65c849be3fed38b005cb1cf8abc6fa037e2cc9475de7c3eca53a1c6c1e +size 9791 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 4caec79857..e5602cc913 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 6482d7322b..bdf129ee4a 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 6f42ac691e..679685e936 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 fddd092e48..e26ea57b89 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 92b79113e1..60a8334d91 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 f59422b33e..9ddb94878d 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 4a6e665500..bdd255116a 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 3ee771684a..e3d68e573f 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 846a262c57..1c308dc42c 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 c56359f7e4..e5feb7324d 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 4551d86254..7df51fb255 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 0beaf993fb..b566c6f449 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 326f3948ed..acd556e96e 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 f583680c90..a65ca8b35c 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 2de8f41575..cec8a0b12a 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 f97044e8b6..26bbc77e27 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 a2a9d84eca..a2bc71b494 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 cdd4983e85..738e779bad 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 f89f4b12b7..5236db62bf 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 4ccf9c5917..bc81f67b2b 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 aebee6a41b..fc16867249 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 42f7ea79a3..08e8504449 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 7313e32cf0..df3b32b8d2 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 a372614326..d460968d64 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 639cfeee1f..6dc48a0630 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 bdf1ea5885..d0b1d953ff 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 465d5a5c11..c86ff3c07e 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 3ba841381f..dab20f0c93 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 ee775246b6..523ffd0d50 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 861384a18e..8b1d78f601 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 b78d36fdd4..a7d1026e41 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 5ef7a3ecaa..319cf76ebc 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 94488a8118..be729307b0 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 31cf97c9c4..568ba4e7b7 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 7cb03f9aac..9a40df428f 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 e008b3ee4e..a55b92e8b8 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 ba46e2e270..35f2d39b79 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 0c23280443..437e1acae5 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 5b5b142b81..7562d7090b 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 9de8ba3f72..883800c68f 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 e7784793c4..a5ed775376 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 5b56c20157..4eac81e304 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 b18545b512..06ba9eb873 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 6ec2ca88f6..d9e77e9bd4 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 638136ddda..5c44befebb 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 ccab6d1cc6..f3f6ee2ee8 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 c2a4773ab4..a438c1fdfe 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 a01a166e95..132c4d7b5f 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 d9592ab2d0..9da26c1c14 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 e6f0772abc..dab02cddf2 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 f18f28fa0f..e9c013be92 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 381119052c..9e0186982d 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 16da419a33..a53692356b 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 dfa1308111..41f97a0155 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 3806b4217c..cb6393e72b 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 cc99ecab5a..79bfeb7250 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