From 4eeff16cc5861e2dcbec30bcdaf51cd10d66585d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 17:49:35 +0200 Subject: [PATCH 01/27] Make sure `CoilMediaFetcher` throws any exception it finds so we can later handle them and check why some media didn't load --- .../android/libraries/matrix/ui/media/CoilMediaFetcher.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 713057ecda7..d31b5671eb2 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -40,7 +40,7 @@ internal class CoilMediaFetcher( onSuccess = { it }, onFailure = { error -> if (error.isNetworkError()) { - null + throw error } else { fetchContent(mediaSource) } @@ -73,7 +73,7 @@ internal class CoilMediaFetcher( .onFailure { Timber.e(it) } - .getOrNull() + .getOrThrow() } private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? { @@ -83,7 +83,7 @@ internal class CoilMediaFetcher( byteArray.asSourceResult() }.onFailure { Timber.e(it) - }.getOrNull() + }.getOrThrow() } private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result { From 22904351d37abbdf5d6204324819f5a2881df2bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:15:13 +0200 Subject: [PATCH 02/27] Add `LocalEventContentValidationState` to be able to track an event content's validation state in any UI component inside the chat screen --- .../DefaultEventContentValidationCache.kt | 26 +++++++ .../features/messages/impl/MessagesNode.kt | 4 + .../ContentValidationState.kt | 52 +++++++++++++ .../EventContentValidationCache.kt | 17 +++++ .../LocalEventContentValidationState.kt | 74 +++++++++++++++++++ tools/detekt/detekt.yml | 1 + 6 files changed, 174 insertions(+) create mode 100644 features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt new file mode 100644 index 00000000000..efaa97fa9cc --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultEventContentValidationCache.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultEventContentValidationCache : EventContentValidationCache { + private val cache = ConcurrentHashMap() + + override operator fun get(eventId: EventId): ContentValidationState { + return cache.getOrPut(eventId) { ContentValidationState() } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index a7e88460473..18c02d8f406 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -66,6 +66,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.ui.strings.CommonStrings @@ -97,6 +99,7 @@ class MessagesNode( private val permalinkParser: PermalinkParser, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, + private val eventContentValidationCache: EventContentValidationCache, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val focusedEventId: EventId?, @@ -261,6 +264,7 @@ class MessagesNode( val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + LocalEventContentValidationState provides eventContentValidationCache, ) { val state = presenter.present() diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt new file mode 100644 index 00000000000..2d388ced2ff --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentValidationState.kt @@ -0,0 +1,52 @@ +/* + * 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.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.architecture.AsyncData + +/** + * Wrapper for the content validation state of an event. It holds a [MutableState] of [AsyncData] that represents the validation state. + */ +@Stable +class ContentValidationState( + val state: MutableState>, +) { + /** + * Creates a new [ContentValidationState] with an uninitialized state. + */ + constructor() : this(state = mutableStateOf(AsyncData.Uninitialized)) + + /** + * Creates a new [ContentValidationState] with a given validation state. + */ + constructor(isValid: Boolean) : this(state = mutableStateOf(AsyncData.Success(isValid))) + + /** + * Returns true if the content is valid, false otherwise. If the state is uninitialized or loading, it returns false. + */ + fun isValid(): Boolean { + return state.value.dataOrNull() == true + } + + /** + * Returns true if the content is invalid, false otherwise. If the state is uninitialized or loading, it returns false. + */ + fun isInvalid(): Boolean { + return state.value.dataOrNull() == false + } + + /** + * Updates the validation state with a new value. + */ + fun update(newState: AsyncData) { + state.value = newState + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt new file mode 100644 index 00000000000..7fa48ad9331 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/EventContentValidationCache.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import io.element.android.libraries.matrix.api.core.EventId + +/** + * A cache that holds the validation state of event contents, based on their [EventId]. + */ +interface EventContentValidationCache { + operator fun get(eventId: EventId): ContentValidationState +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt new file mode 100644 index 00000000000..f3c21637974 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/LocalEventContentValidationState.kt @@ -0,0 +1,74 @@ +/* + * 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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.libraries.architecture.AsyncData +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 + +/** + * 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 { + return ContentValidationState(mutableStateOf(AsyncData.Success(true))) + } +} + +/** + * 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 ContentValidationState(isValid = true) + } + + return rememberEventContentValidationStateInternal(eventId) +} + +@Composable +private fun rememberEventContentValidationStateInternal(eventId: EventId?): ContentValidationState { + val cache = LocalEventContentValidationState.current + return remember(eventId) { + if (eventId != null) { + cache[eventId] + } else { + ContentValidationState() + } + } +} diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index cc99ecab5a8..79bfeb7250e 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -227,6 +227,7 @@ Compose: # You can optionally define a list of CompositionLocals that are allowed here allowedCompositionLocals: - LocalCompoundColors + - LocalEventContentValidationState - LocalSnackbarDispatcher - LocalCameraPositionState - LocalMediaItemPresenterFactories From 9af9587b6144fe3954f68ca326c9cac8dfb038db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:16:55 +0200 Subject: [PATCH 03/27] Add `InvalidContentView` to be used when the media content is invalid --- .../contentvalidation/InvalidContentView.kt | 82 +++++++++++++++++++ .../src/main/res/values/localazy.xml | 4 + 2 files changed, 86 insertions(+) create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt new file mode 100644 index 00000000000..26bea65c0f8 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InvalidContentView.kt @@ -0,0 +1,82 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * A view to display when the media content is invalid or dangerous. + */ +@Composable +fun InvalidContentView( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues.Zero, + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val updatedOnTextLayout by rememberUpdatedState(onTextLayout) + Row( + modifier = modifier + .background(color = ElementTheme.colors.bgCriticalSubtle) + .padding(contentPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = CompoundIcons.Error(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary + ) + Column(Modifier.weight(1f)) { + Text( + text = stringResource(CommonStrings.content_scanner_unsafe_title), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + val text = stringResource(CommonStrings.content_scanner_unsafe_message) + val textMeasurer = rememberTextMeasurer() + LaunchedEffect(text) { + updatedOnTextLayout(textMeasurer.measure(text)) + } + Text( + text = text, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun InvalidContentViewPreview() = ElementPreview { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + InvalidContentView() + } +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 82acfd14b35..d23f2acb593 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -434,6 +434,10 @@ Reason: %1$s." "Waiting for live location…" "Anyone can see history" "You" + "Download not available." + "File not found" + "Preview and download have been disabled." + "The file is not safe" "%1$s (%2$s) shared this message since you were not in the room when it was sent." "%1$s shared this message since you were not in the room when it was sent." "This room has been configured so that new members can read history. %1$s" From eeb39c83e1f73912e2f965d8cc9d5e4b7b1887f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:27:02 +0200 Subject: [PATCH 04/27] Use `InvalidContentView` along with the cache inside the content views for images, videos and stickers, so we can check if the thumbnail had already been validated --- .../components/TimelineItemEventRow.kt | 1 + .../event/TimelineItemEventContentView.kt | 44 +++++++ .../components/event/TimelineItemImageView.kt | 101 +++++++++----- .../event/TimelineItemStickerView.kt | 96 ++++++++------ .../components/event/TimelineItemVideoView.kt | 124 +++++++++++------- 5 files changed, 251 insertions(+), 115 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index c73248af532..008580d8efb 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 @@ -176,6 +176,7 @@ fun TimelineItemEventRow( val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } TimelineItemEventContentView( + eventId = event.eventId, content = event.content, hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), onContentClick = onContentClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 2951d13d656..3b073ac963d 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 @@ -34,11 +34,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemEventContentView( + eventId: EventId?, content: TimelineItemEventContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, @@ -51,6 +55,7 @@ fun TimelineItemEventContentView( modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { + val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia) val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( @@ -139,6 +144,45 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier ) + is TimelineItemImageContent -> { + TimelineItemImageView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + contentValidationState = contentValidationState, + modifier = modifier, + ) + } + is TimelineItemStickerContent -> { + TimelineItemStickerView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowClick = onShowContentClick, + modifier = modifier, + contentValidationState = contentValidationState, + ) + } + is TimelineItemVideoContent -> { + TimelineItemVideoView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + contentValidationState = contentValidationState, + modifier = modifier + ) + } is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) is TimelineItemStateContent -> TimelineItemStateView( content = content, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 0e0a98a96cc..1aea58d7e98 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -12,6 +12,7 @@ import android.text.SpannedString import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -48,10 +49,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.matrix.api.exception.ClientException +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.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -68,8 +74,9 @@ fun TimelineItemImageView( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onShowContentClick: () -> Unit, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, ) { val a11yLabel = stringResource(CommonStrings.common_image) val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel @@ -79,38 +86,70 @@ fun TimelineItemImageView( } else { Modifier } - TimelineItemAspectRatioBox( - modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowContentClick, + + val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading + val isDangerous = contentValidationState.isInvalid() + if (isContentBeingValidated || !isDangerous) { + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) - .then( - if (!isTalkbackActive() && onContentClick != null) { - Modifier - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick, - ) - .onKeyboardContextMenuAction(onLongClick) - } else { - Modifier - } - ), - model = content.thumbnailMediaRequestData, - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive() && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = content.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { + isLoaded = it is AsyncImagePainter.State.Success + val isDangerousFile = if (it is AsyncImagePainter.State.Error) { + val cause = it.result.throwable + cause is ClientException.ContentScanner && cause.reason.isDangerous() + } else { + false + } + contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + }, + ) + } + } } + } else { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = if (!content.showCaption) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) } if (content.showCaption) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index 104e420d51b..b67521b4d60 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 @@ -31,11 +31,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt 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.libraries.architecture.AsyncData 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.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @@ -48,51 +51,66 @@ fun TimelineItemStickerView( onLongClick: (() -> Unit)?, onShowClick: () -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) - Column( - modifier = modifier.semantics { contentDescription = description }, - ) { - TimelineItemAspectRatioBox( - modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - minHeight = STICKER_SIZE_IN_DP, - maxHeight = STICKER_SIZE_IN_DP, + + val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading + val isDangerous = contentValidationState.isInvalid() + if (isContentBeingValidated || !isDangerous) { + Column( + modifier = modifier.semantics { contentDescription = description }, ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowClick, + TimelineItemAspectRatioBox( + modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + minHeight = STICKER_SIZE_IN_DP, + maxHeight = STICKER_SIZE_IN_DP, ) { - var isLoaded by remember { mutableStateOf(false) } - AsyncImage( - modifier = Modifier - .fillMaxSize() - .then(if (isLoaded) Modifier.background(Color.White) else Modifier) - .then( - if (onContentClick != null) { - Modifier - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick, - onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ) - .onKeyboardContextMenuAction(onLongClick) + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowClick, + ) { + var isThumbnailLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxSize() + .then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.preferredMediaSource, + kind = MediaRequestData.Kind.File( + fileName = content.filename, + mimeType = content.mimeType, + ), + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { + isThumbnailLoaded = it is AsyncImagePainter.State.Success + val isDangerousFile = if (it is AsyncImagePainter.State.Error) { + val cause = it.result.throwable + cause is ClientException.ContentScanner && cause.reason.isDangerous() } else { - Modifier + false } - ), - model = MediaRequestData( - source = content.preferredMediaSource, - kind = MediaRequestData.Kind.File( - fileName = content.filename, - mimeType = content.mimeType, - ), - ), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) + contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + }, + ) + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 8d1ef18f39f..3c791dc01f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -54,14 +55,18 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -79,6 +84,7 @@ fun TimelineItemVideoView( onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { val isTalkbackActive = isTalkbackActive() val a11yLabel = stringResource(CommonStrings.common_video) @@ -91,57 +97,85 @@ fun TimelineItemVideoView( } else { Modifier } - TimelineItemAspectRatioBox( - modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), - aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), - contentAlignment = Alignment.Center, - ) { - ProtectedView( - hideContent = hideMediaContent, - onShowClick = onShowContentClick, + + val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading + val isDangerous = contentValidationState.isInvalid() + if (isContentBeingValidated || !isDangerous) { + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + contentAlignment = Alignment.Center, ) { - 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) + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail( + width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, + height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, + ) + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { + isLoaded = it is AsyncImagePainter.State.Success + val isDangerousFile = if (it is AsyncImagePainter.State.Error) { + val cause = it.result.throwable + cause is ClientException.ContentScanner && cause.reason.isDangerous() } else { - Modifier + false } - ), - model = MediaRequestData( - source = content.thumbnailSource, - kind = MediaRequestData.Kind.Thumbnail( - width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, - height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, - ) - ), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - contentDescription = description, - onState = { isLoaded = it is AsyncImagePainter.State.Success }, - ) - - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, - ) { - Image( - imageVector = CompoundIcons.PlaySolid(), - contentDescription = stringResource(id = CommonStrings.a11y_play), - colorFilter = ColorFilter.tint(Color.White), - modifier = Modifier.semantics { hideFromAccessibility() } + contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + }, ) + + 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 { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = if (!content.showCaption) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) } if (content.showCaption) { From a6a0cfc26cc14bb808c21fbd27cec4e9eac2ee94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:29:24 +0200 Subject: [PATCH 05/27] Adjust the design of the message bubble based on the content validation state: invalid contents will display a red background and border --- .../timeline/components/MessageEventBubble.kt | 10 +- .../components/TimelineItemEventRow.kt | 64 ++++++++---- .../event/TimelineItemEventContentView.kt | 97 ++++++++----------- 3 files changed, 96 insertions(+), 75 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 80bd342d018..1bae7f3274e 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 @@ -27,6 +27,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 +64,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,7 +83,7 @@ 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 = 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() @@ -100,6 +104,10 @@ fun MessageEventBubble( // Draw the background first, so that it's behind the content drawRect(backgroundBubbleColor) + if (borderColor != null) { + drawOutline(outline, borderColor, style = Stroke(width = 1.dp.toPx())) + } + // Then draw the content on top of it drawContent() 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 008580d8efb..202d37f06c2 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 @@ -123,8 +123,10 @@ 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.user.MatrixUser +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 @@ -469,6 +471,18 @@ private fun TimelineItemEventRowContent( ) } + val isContentInvalid = rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).isInvalid() + + // If the event has a dangerous media content without a preview, we need to set a custom message bubble background color + val themeColors = ElementTheme.colors + val dangerousContentBubbleColor = remember(themeColors.isLight, isContentInvalid, event.content.type) { + themeColors.bgCriticalSubtle.takeIf { isContentInvalid && event.content.isMedia } + } + + val borderColor = remember(themeColors.isLight, isContentInvalid) { + themeColors.borderCriticalSubtle.takeIf { isContentInvalid } + } + // Message bubble val bubbleState = BubbleState( groupPosition = event.groupPosition, @@ -495,6 +509,8 @@ private fun TimelineItemEventRowContent( interactionSource = interactionSource, onClick = onContentClick, onLongClick = onLongClick, + customBackgroundColor = dangerousContentBubbleColor, + borderColor = borderColor, ) { MessageEventBubbleContent( event = event, @@ -771,10 +787,12 @@ private fun MessageEventBubbleContent( } val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> + val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content()) 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 @@ -783,14 +801,18 @@ private fun MessageEventBubbleContent( } else { inReplyToModifier.clickable(onClick = inReplyToClick) } + + val borderColor = if (contentValidationState.isInvalid()) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary + val backgroundColor = if (contentValidationState.isInvalid()) 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) ) { InReplyToView( inReplyTo = inReplyTo, + isContentValid = contentValidationState.state.value.dataOrNull(), hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), ) } @@ -810,22 +832,30 @@ 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 isContentDangerous = rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).isInvalid() + + val timestampPosition = if (isContentDangerous) { + // THe invalid content view will be displayed in all these cases, independent of the event content + TimestampPosition.Aligned + } else { + when (val content = event.content) { + is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below + is TimelineItemAttachmentsContent -> TimestampPosition.Below + is TimelineItemStickerContent -> TimestampPosition.Overlay + is TimelineItemLocationContent -> { + val content = content.ensureActiveLiveLocation() + val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && + content.mode.isActive && + content.mode.isOwnUser + if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay + } + is TimelineItemPollContent -> TimestampPosition.Below + else -> TimestampPosition.Default } - is TimelineItemPollContent -> TimestampPosition.Below - else -> TimestampPosition.Default } + val paddingBehaviour = when (event.content) { is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 3b073ac963d..2daf82a796c 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 @@ -55,7 +55,22 @@ fun TimelineItemEventContentView( modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { + val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) { + timelineProtectionState.hideMediaContent(eventId) + } + + val onShowContentClick = remember(timelineProtectionState.eventSink) { + { + timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId)) + } + } + val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia) + + if (eventId != null) { + ValidateMediaHelper(eventId, content, contentValidationState, eventSink) + } + val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( @@ -87,63 +102,6 @@ fun TimelineItemEventContentView( modifier = modifier ) } - is TimelineItemImageContent -> TimelineItemImageView( - content = content, - hideMediaContent = hideMediaContent, - onContentClick = onContentClick, - onLongClick = onLongClick, - onShowContentClick = onShowContentClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentLayoutChange = onContentLayoutChange, - modifier = modifier, - ) - is 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 TimelineItemImageContent -> { TimelineItemImageView( content = content, @@ -183,6 +141,31 @@ fun TimelineItemEventContentView( modifier = modifier ) } + is TimelineItemGalleryContent -> TimelineItemGalleryView( + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + onLongClick = onLongClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + ) + is TimelineItemFileContent -> { + TimelineItemFileView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + isDangerous = contentValidationState.state.value.dataOrNull()?.not(), + ) + } + is TimelineItemAudioContent -> { + TimelineItemAudioView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + isDangerous = contentValidationState.state.value.dataOrNull()?.not(), + ) + } is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) is TimelineItemStateContent -> TimelineItemStateView( content = content, From 2a42b3e08fc9322bcf4ee15b9b878edacdda836b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:31:10 +0200 Subject: [PATCH 06/27] Disable clicking on events with invalid content --- .../element/android/features/messages/impl/MessagesView.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 8887424dc6c..c1d48ce303a 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,7 @@ 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.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 +149,8 @@ fun MessagesView( forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, ) { + val eventContentValidationState = LocalEventContentValidationState.current + OnLifecycleEvent { _, event -> state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event)) } @@ -170,6 +173,9 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") + val eventId = event.eventId ?: return + if (eventContentValidationState.get(eventId).isInvalid()) return + val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { localView.hideKeyboard() From bb3552bd402e443625aacc945135f03c2aa85342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:32:42 +0200 Subject: [PATCH 07/27] Ensure `TimelineItemEventContentView` in other places also passes an `eventId`, needed for the content validation --- .../messages/impl/pinned/list/PinnedMessagesListView.kt | 1 + .../impl/timeline/components/TimelineItemGroupedEventsRow.kt | 2 ++ .../impl/timeline/components/TimelineItemStateEventRow.kt | 1 + 3 files changed, 4 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index b4fe504ab17..ca420fb35f5 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 @@ -290,6 +290,7 @@ 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)) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index ed927956df0..8da8c10cb5f 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 @@ -60,6 +60,7 @@ 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)) }, @@ -132,6 +133,7 @@ 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)) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index a6015758fce..19eeb579052 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 @@ -67,6 +67,7 @@ fun TimelineItemStateEventRow( .widthIn(max = 320.dp) ) { TimelineItemEventContentView( + eventId = event.eventId, content = event.content, onLinkClick = {}, onLinkLongClick = {}, From 17f8ea7f4bb453124f0e75c645b2a66cc984052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:38:05 +0200 Subject: [PATCH 08/27] Add `ContentScannerService` for running manual scans besides those the SDK will do automatically --- .../api/ContentScannerService.kt | 22 ++++ .../impl/DefaultContentScannerService.kt | 105 ++++++++++++++++++ .../impl/di/ContentScannerModule.kt | 42 +++++++ .../impl/di/NoopContentScannerService.kt | 25 +++++ 4 files changed, 194 insertions(+) create mode 100644 features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt create mode 100644 features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt create mode 100644 features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt create mode 100644 features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt diff --git a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt new file mode 100644 index 00000000000..a417cb8a8d1 --- /dev/null +++ b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.api + +import io.element.android.libraries.architecture.AsyncData +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 + +/** + * Service to perform security scans in the contents for a given event and media source. + * + * The default implementation does nothing, unless a [ContentScanner] is provided when building the client. + */ +interface ContentScannerService { + fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt new file mode 100644 index 00000000000..7ec037913e5 --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.contentscanner.impl + +import androidx.collection.LruCache +import androidx.compose.runtime.MutableState +import io.element.android.features.contentscanner.api.ContentScannerService +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.IOException + +/** + * Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources. + * + * When a scan is requested for a given event and media source, it'll check if a scan is really needed (the media source has not been scanned before), + * then it will use a per-event mutex to avoid performing concurrent scans for thumbnail and full-size media sources. + * + * If, before starting a new scan, the cached result is already a failure (either we're scanning the full media source and the thumbnail already failed + * or the other way around), it will not perform the scan again. + */ +class DefaultContentScannerService( + private val contentScanner: ContentScanner, + private val eventContentValidationCache: EventContentValidationCache, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) : ContentScannerService { + private val cache = LruCache(100) + + override fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) { + val url = mediaSource.safeUrl + val cachedResult = cache[eventId] + val needsScan = cachedResult?.needsScan(url) ?: true + if (!needsScan) { + updateState(cachedResult.state.value) + return + } + + val cachedValue = cache.getOrPut(eventId) { + ContentScannerResult( + eventId = eventId, + mutex = Mutex(), + state = eventContentValidationCache[eventId].state, + handledSources = mutableSetOf(), + ) + } + + sessionCoroutineScope.launch { + cachedValue.mutex.withLock { + if (cachedValue.state.value.dataOrNull() == false) { + // If the cached result is already a failure, we don't need to scan again + return@launch + } + updateState(AsyncData.Loading()) + contentScanner.scan(mediaSource) + .onSuccess { + val cachedValue = cache[eventId] + updateState(AsyncData.Success(it)) + + cachedValue?.addHandledSource(url) + }.onFailure { exception -> + if (exception is IOException) { + // If it's an IO-related exception, we can retry later, so we don't cache it + updateState(AsyncData.Uninitialized) + } else { + // For other exceptions, we cache the failure + updateState(AsyncData.Failure(exception)) + + cachedValue.addHandledSource(url) + } + } + } + } + } +} + +private data class ContentScannerResult( + val eventId: EventId, + val mutex: Mutex, + val state: MutableState>, + val handledSources: MutableSet, +) { + fun addHandledSource(url: String) { + handledSources.add(url) + } + + fun needsScan(url: String): Boolean { + return !handledSources.contains(url) + } +} + +private fun LruCache.getOrPut(key: K, defaultValue: (K) -> V): V { + return this[key] ?: defaultValue(key).also { put(key, it) } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt new file mode 100644 index 00000000000..bec545c00f5 --- /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.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import kotlinx.coroutines.CoroutineScope + +@BindingContainer +@ContributesTo(SessionScope::class) +object ContentScannerModule { + @Provides + @SingleIn(SessionScope::class) + fun providesContentScannerService( + contentScanner: ContentScanner?, + eventContentValidationCache: EventContentValidationCache, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, + ): ContentScannerService { + return if (contentScanner != null) { + DefaultContentScannerService( + contentScanner = contentScanner, + eventContentValidationCache = eventContentValidationCache, + sessionCoroutineScope = sessionCoroutineScope + ) + } else { + NoopContentScannerService() + } + } +} diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt new file mode 100644 index 00000000000..c77fdf84b4c --- /dev/null +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt @@ -0,0 +1,25 @@ +/* + * 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.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource + +/** + * Noop implementation of [ContentScannerService] that always returns success. + * + * This is used when the content scanner feature is not enabled or available. + */ +class NoopContentScannerService : ContentScannerService { + override fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) { + // Always return success for the noop implementation + updateState(AsyncData.Success(true)) + } +} From 28f422282fb4bd3408bb8b8411b8201539cf7c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:44:33 +0200 Subject: [PATCH 09/27] Add the option to being able to manually scan media from the timeline using `TimelineProtectionState` --- features/messages/impl/build.gradle.kts | 1 + .../pinned/list/PinnedMessagesListView.kt | 4 +--- .../messages/impl/timeline/TimelineEvent.kt | 8 ++++++++ .../impl/timeline/TimelinePresenter.kt | 11 +++++++++- .../components/TimelineItemEventRow.kt | 4 +--- .../TimelineItemGroupedEventsRow.kt | 7 ++----- .../timeline/components/TimelineItemRow.kt | 6 +++--- .../components/TimelineItemStateEventRow.kt | 7 +++++-- .../protection/TimelineProtectionEvent.kt | 7 +++++++ .../protection/TimelineProtectionPresenter.kt | 20 +++++++++++++++---- 10 files changed, 54 insertions(+), 21 deletions(-) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 2661f7e3306..116a3a7c41b 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) implementation(projects.features.networkmonitor.api) + implementation(projects.features.contentscanner.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index ca420fb35f5..bdad9ba6310 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule @@ -292,8 +291,7 @@ private fun TimelineItemEventContentViewWrapper( TimelineItemEventContentView( eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onGalleryItemClick = onGalleryItemClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 5330fc00d35..a884fc05c89 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 mediaSource: MediaSource, + val validationState: ContentValidationState + ) : TimelineItemEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 00039dc1af6..2a78c67f858 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.mediaSource, 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/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 202d37f06c2..4558520f2d9 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 @@ -88,7 +88,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 @@ -180,11 +179,10 @@ fun TimelineItemEventRow( 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, 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 8da8c10cb5f..777776ad908 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -62,8 +61,7 @@ fun TimelineItemGroupedEventsRow( 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, @@ -135,8 +133,7 @@ private fun TimelineItemGroupedEventsRowContent( TimelineItemEventContentView( eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 6a5cbfd9dd2..20444903adc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent -import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.designsystem.colors.gradientSubtleColors import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction @@ -77,9 +76,9 @@ internal fun TimelineItemRow( eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( + eventId = event.eventId, content = event.content, - hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), - onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + timelineProtectionState = timelineProtectionState, onContentClick = { onContentClick(event) }, onGalleryItemClick = { index -> onGalleryItemClick(event, index) }, onLongClick = { onLongClick(event) }, @@ -119,6 +118,7 @@ internal fun TimelineItemRow( onClick = { onContentClick(timelineItem) }, onReadReceiptsClick = onReadReceiptClick, onLongClick = { onLongClick(timelineItem) }, + timelineProtectionState = timelineProtectionState, eventSink = eventSink, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index 19eeb579052..eb27134065e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -43,6 +45,7 @@ fun TimelineItemStateEventRow( onClick: () -> Unit, onLongClick: () -> Unit, onReadReceiptsClick: (event: TimelineItem.Event) -> Unit, + timelineProtectionState: TimelineProtectionState, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier ) { @@ -71,8 +74,7 @@ fun TimelineItemStateEventRow( content = event.content, onLinkClick = {}, onLinkLongClick = {}, - hideMediaContent = false, - onShowContentClick = {}, + timelineProtectionState = timelineProtectionState, eventSink = eventSink, onContentClick = null, onGalleryItemClick = {}, @@ -108,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/protection/TimelineProtectionEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt index 3d752687773..45871cdfbc2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt @@ -9,7 +9,14 @@ package io.element.android.features.messages.impl.timeline.protection 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 sealed interface TimelineProtectionEvent { data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent + data class ValidateContent( + val eventId: EventId, + val mediaSource: MediaSource, + val validationState: ContentValidationState, + ) : TimelineProtectionEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index b2ea1d6bd85..a3a4302c509 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,29 @@ 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 +import kotlinx.coroutines.launch +@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 +42,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 +57,12 @@ 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 -> room.roomCoroutineScope.launch { + contentScannerService.scan(event.eventId, event.mediaSource) { + event.validationState.state.value = it + } } } } From 10b117082c3840b0da601a7980210619175618c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:50:17 +0200 Subject: [PATCH 10/27] Add `ValidateMediaHelper` to manually scan the media content in the timeline items, besides the thumbnails that are displayed --- .../event/TimelineItemAttachmentView.kt | 55 +++++-- .../components/event/TimelineItemAudioView.kt | 4 + .../event/TimelineItemEventContentView.kt | 38 ++++- .../components/event/TimelineItemFileView.kt | 4 + .../components/event/TimelineItemVoiceView.kt | 150 ++++++++++-------- .../model/event/TimelineItemEventContent.kt | 3 + 6 files changed, 169 insertions(+), 85 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt index 4aa41a472b1..58663415001 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -12,11 +12,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -25,8 +27,11 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. @@ -41,18 +46,32 @@ fun TimelineItemAttachmentView( caption: String?, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { Column( modifier = modifier, ) { - TimelineItemAttachmentHeaderView( - icon = icon, - iconContentDescription = iconContentDescription, - filename = filename, - fileExtensionAndSize = fileExtensionAndSize, - hasCaption = caption != null, - onContentLayoutChange = onContentLayoutChange, - ) + if (!contentValidationState.isInvalid()) { + TimelineItemAttachmentHeaderView( + icon = icon, + iconContentDescription = iconContentDescription, + filename = filename, + fileExtensionAndSize = fileExtensionAndSize, + hasCaption = caption != null, + isLoading = contentValidationState.state.value.isLoading(), + onContentLayoutChange = onContentLayoutChange, + ) + } else { + InvalidContentView( + // Add some spacing between the invalid content view and the caption + contentPadding = if (caption != null) PaddingValues(bottom = 8.dp) else PaddingValues.Zero, + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + ) + } if (caption != null) { TimelineItemAttachmentCaptionView( modifier = Modifier.padding(top = 4.dp), @@ -70,6 +89,7 @@ private fun TimelineItemAttachmentHeaderView( filename: String, fileExtensionAndSize: String, hasCaption: Boolean, + isLoading: Boolean, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -86,12 +106,19 @@ private fun TimelineItemAttachmentHeaderView( .background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = iconContentDescription, - tint = ElementTheme.colors.iconPrimary, - modifier = Modifier.size(24.dp), - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = ElementTheme.colors.iconPrimary, + ) + } else { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.size(24.dp), + ) + } } Column { Text( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d5a07194cf0..52aa1e9e616 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons @@ -16,12 +17,14 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember {ContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Audio(), @@ -31,6 +34,7 @@ fun TimelineItemAudioView( caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationState = contentValidationState, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 2daf82a796c..bd66381bb26 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -9,6 +9,10 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData @@ -33,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState @@ -44,11 +50,10 @@ import io.element.android.wysiwyg.link.Link 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, @@ -155,7 +160,7 @@ fun TimelineItemEventContentView( content = content, onContentLayoutChange = onContentLayoutChange, modifier = modifier, - isDangerous = contentValidationState.state.value.dataOrNull()?.not(), + contentValidationState = contentValidationState, ) } is TimelineItemAudioContent -> { @@ -163,7 +168,7 @@ fun TimelineItemEventContentView( content = content, onContentLayoutChange = onContentLayoutChange, modifier = modifier, - isDangerous = contentValidationState.state.value.dataOrNull()?.not(), + contentValidationState = contentValidationState, ) } is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) @@ -182,9 +187,32 @@ fun TimelineItemEventContentView( state = presenter.present(), content = content, onContentLayoutChange = onContentLayoutChange, - modifier = modifier + modifier = modifier, + isDangerousContent = contentValidationState.state.value.dataOrNull()?.not(), ) } is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") } } + +@Composable +private fun ValidateMediaHelper( + eventId: EventId, + content: TimelineItemEventContent, + contentValidationState: ContentValidationState, + eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, +) { + val mediaSource = when (content) { + is TimelineItemImageContent -> content.mediaSource + is TimelineItemStickerContent -> content.mediaSource + is TimelineItemVideoContent -> content.mediaSource + is TimelineItemFileContent -> content.mediaSource + is TimelineItemAudioContent -> content.mediaSource + is TimelineItemVoiceContent -> content.mediaSource + else -> return + } + val updatedEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(eventId, mediaSource) { + updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSource, contentValidationState)) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 7f8be6e60a8..d3b67d21586 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -17,6 +18,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.ContentValidationState import io.element.android.libraries.ui.strings.CommonStrings /** @@ -27,6 +29,7 @@ fun TimelineItemFileView( content: TimelineItemFileContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Attachment(), @@ -36,6 +39,7 @@ fun TimelineItemFileView( caption = content.caption, onContentLayoutChange = onContentLayoutChange, modifier = modifier, + contentValidationState = contentValidationState, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 86e3f1c8492..463299e36da 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 @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider @@ -51,6 +52,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.InvalidContentView 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,81 +66,97 @@ fun TimelineItemVoiceView( content: TimelineItemVoiceContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, + isDangerousContent: Boolean? = false, ) { - fun playPause() { - state.eventSink(VoiceMessageEvent.PlayPause) - } - - val a11y = stringResource(CommonStrings.common_voice_message) - val a11yActionLabel = stringResource( - when (state.buttonType) { - VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play - VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause - VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading - VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry - VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown + if (isDangerousContent == true) { + InvalidContentView( + modifier = modifier, + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ), + ) + } else { + fun playPause() { + state.eventSink(VoiceMessageEvent.PlayPause) } - ) - Row( - modifier = modifier - .clearAndSetSemantics { - contentDescription = a11y - if (state.buttonType == VoiceMessageState.ButtonType.Disabled) { - disabled() - } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) { - onClick(label = a11yActionLabel) { - playPause() - true + + val a11y = stringResource(CommonStrings.common_voice_message) + val a11yActionLabel = stringResource( + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play + VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause + VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading + VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry + VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown + } + ) + Row( + modifier = modifier + .clearAndSetSemantics { + contentDescription = a11y + if (state.buttonType == VoiceMessageState.ButtonType.Disabled) { + disabled() + } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) { + onClick(label = a11yActionLabel) { + playPause() + true + } } } - } - .onSizeChanged { - onContentLayoutChange( - ContentAvoidingLayoutData( - contentWidth = it.width, - contentHeight = it.height, + .onSizeChanged { + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = it.width, + contentHeight = it.height, + ) ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isTalkbackActive()) { + if (isDangerousContent == false) { + 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 if (isDangerousContent == null) { + ProgressButton(displayImmediately = true) + } + } + Spacer(Modifier.width(8.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, + ) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - if (!isTalkbackActive()) { - when (state.buttonType) { - VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Downloading -> ProgressButton() - VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) - VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } - } - Spacer(Modifier.width(8.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - PlaybackSpeedButton( - speed = state.playbackSpeed, - onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, - ) - Text( - text = state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Spacer(Modifier.width(8.dp)) + WaveformPlaybackView( + showCursor = state.showCursor, + playbackProgress = state.progress, + waveform = content.waveform, + modifier = Modifier + .weight(1f) + .height(34.dp), + seekEnabled = !isTalkbackActive(), + onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, ) } - Spacer(Modifier.width(8.dp)) - WaveformPlaybackView( - showCursor = state.showCursor, - playbackProgress = state.progress, - waveform = content.waveform, - modifier = Modifier - .weight(1f) - .height(34.dp), - seekEnabled = !isTalkbackActive(), - onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, - ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 5aa99fd4eb9..4b8f643774d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -15,6 +15,9 @@ import kotlin.time.Duration @Immutable sealed interface TimelineItemEventContent { val type: String + + val isMedia: Boolean + get() = this is TimelineItemEventContentWithAttachment } interface TimelineItemEventMutableContent { From a79f2cd1e906f3527b94bc19c2f3cc8cdabfe8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:50:39 +0200 Subject: [PATCH 11/27] Add missing blurhash for the thumbnail of the video timeline items --- .../libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt index 7d9e6346879..3aad3f10612 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -34,6 +34,7 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -51,6 +52,7 @@ fun VideoItemView( ) { Box( modifier = modifier + .blurHashBackground(blurHash = video.blurHash) .aspectRatio(1f) .combinedClickable( onClick = onClick, From df46c0ce65b48094d6e74db1f5835f19642c2e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 18:54:34 +0200 Subject: [PATCH 12/27] Handle content in replies too --- .../components/TimelineItemEventRow.kt | 2 +- .../ui/messages/reply/InReplyToDetails.kt | 6 ++++ .../matrix/ui/messages/reply/InReplyToView.kt | 31 +++++++++++++++-- .../textcomposer/ComposerModeView.kt | 33 +++++++++++++++---- 4 files changed, 62 insertions(+), 10 deletions(-) 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 4558520f2d9..d2754790cb9 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 @@ -810,7 +810,7 @@ private fun MessageEventBubbleContent( ) { InReplyToView( inReplyTo = inReplyTo, - isContentValid = contentValidationState.state.value.dataOrNull(), + contentValidationState = contentValidationState, hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt index 3179c748ea5..2105655b01a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt @@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) { is InReplyToDetails.Error -> eventId } +fun InReplyToDetails.content() = when (this) { + is InReplyToDetails.Ready -> eventContent + is InReplyToDetails.Loading -> null + is InReplyToDetails.Error -> null +} + fun InReplyTo.map( permalinkParser: PermalinkParser, ) = when (this) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 4d0e02aa6f0..f12eeabb508 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,6 +45,8 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.ui.strings.CommonStrings @@ -57,16 +60,19 @@ fun InReplyToView( hideImage: Boolean, modifier: Modifier = Modifier, maxLines: Int = 2, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { when (inReplyTo) { - is InReplyToDetails.Ready -> { - ReplyToReadyContent( + is InReplyToDetails.Ready -> when { + contentValidationState.isValid() -> ReplyToReadyContent( senderId = inReplyTo.senderId, senderProfile = inReplyTo.senderProfile, metadata = inReplyTo.metadata(hideImage), maxLines = maxLines, modifier = modifier, ) + contentValidationState.isInvalid() -> ReplyToInvalidContent() + else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) @@ -86,7 +92,7 @@ private fun ReplyToReadyContent( val paddings = if (metadata is InReplyToMetadata.Thumbnail) { PaddingValues(end = 8.dp) } else { - PaddingValues(start = 8.dp, end = 8.dp) + PaddingValues(horizontal = 8.dp) } Row( modifier @@ -213,11 +219,30 @@ private fun ReplyToContentText( } } +@Composable +private fun ReplyToInvalidContent( + modifier: Modifier = Modifier +) { + InvalidContentView( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp) + ) +} + @PreviewsDayNight @Composable internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { InReplyToView( inReplyTo = inReplyTo, hideImage = false, + contentValidationState = remember { ContentValidationState(isValid = true) }, ) } + +@PreviewsDayNight +@Composable +internal fun ReplyToInvalidContentPreview() { + ElementPreview { + ReplyToInvalidContent(modifier = Modifier.padding(10.dp)) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt index 039f5165475..6c969450e75 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 @@ -35,8 +36,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.ContentValidationState +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 +138,45 @@ private fun ReplyToModeView( onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + val shape = RoundedCornerShape(6.dp) + val contentValidationState = if (replyToDetails is InReplyToDetails.Ready) { + rememberEventContentValidationState(replyToDetails.eventId(), replyToDetails.eventContent) + } else { + remember { ContentValidationState() } + } + + Box( modifier - .clip(RoundedCornerShape(6.dp)) - .background(ElementTheme.colors.bgCanvasDefault) - .border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) + .clip(shape) + .background( + color = if (contentValidationState.isInvalid()) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault, + shape = shape, + ) + .border( + width = 1.dp, + color = if (contentValidationState.isInvalid()) 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, + contentValidationState = contentValidationState, maxLines = maxReplyContentLines, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close), - tint = ElementTheme.colors.iconSecondary, + tint = if (contentValidationState.isInvalid()) 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( From 586873ef84eb18e7d2404e4209b04f9e611573c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 09:24:17 +0200 Subject: [PATCH 13/27] Include `isMediaContent` --- .../matrix/api/timeline/item/event/EventContent.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index f323b316f7b..f064c481da7 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,11 @@ data class CallNotifyContent( ) : EventContent data object UnknownContent : EventContent + +fun EventContent.isMediaContent(): Boolean { + return when (this) { + is MessageContent -> type is MessageTypeWithAttachment + is StickerContent -> true + else -> false + } +} From 74f84a00fc8a177869fc10ce129a2f761c7442b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 09:32:56 +0200 Subject: [PATCH 14/27] Add `MediaItem.blurHash` to image and video items --- .../android/features/messages/impl/MessagesFlowNode.kt | 4 ++++ .../timeline/model/event/TimelineItemEventContent.kt | 9 +++++++++ .../libraries/mediaviewer/api/MediaViewerEntryPoint.kt | 2 ++ .../mediaviewer/impl/DefaultMediaViewerEntryPoint.kt | 3 +++ .../mediaviewer/impl/datasource/EventItemFactory.kt | 9 ++++++--- .../impl/gallery/root/MediaGalleryFlowNode.kt | 5 +++++ .../libraries/mediaviewer/impl/model/MediaItem.kt | 10 ++++++++++ .../mediaviewer/impl/model/MediaItemFactories.kt | 2 ++ .../impl/viewer/SingleMediaGalleryDataSource.kt | 2 ++ 9 files changed, 43 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index cbab26fe549..dd7a8b93690 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() { @@ -920,6 +923,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/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 4b8f643774d..c5ae6c05fe5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -126,3 +126,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? { else -> null } } + +fun TimelineItemEventContentWithAttachment.blurHash(): String? { + return when (this) { + is TimelineItemImageContent -> blurhash + is TimelineItemVideoContent -> blurHash + is TimelineItemStickerContent -> blurhash + else -> null + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index f6de6cf8399..7b9f65e60e9 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Params data class EventGallery( @@ -55,6 +56,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val avatarInfo: AvatarInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Params } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 10858c76c86..a9923254f48 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,9 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint { ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, + canShowInfo = false, + // Maybe add avatar blurhash if available? + blurHash = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 05d5dd40229..1a14c2d4797 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 @@ -242,8 +242,9 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) - is StickerMessageType -> listOf(MediaItem.Image( + is StickerMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( @@ -264,8 +265,9 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) - is VideoMessageType -> listOf(MediaItem.Video( + is VideoMessageType -> MediaItem.Video( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( @@ -286,8 +288,9 @@ class EventItemFactory( ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, + blurHash = type.info?.blurhash, )) - is VoiceMessageType -> listOf(MediaItem.Voice( + is VoiceMessageType -> MediaItem.Voice( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt index 611d619d520..3adddd2fd89 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,8 @@ class MediaGalleryFlowNode( mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, + canShowInfo = true, + blurHash = navTarget.blurHash, ), callback = callback, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt index 90708db802a..5a0b56a9b7e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -35,6 +35,7 @@ sealed interface MediaItem { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Event { val thumbnailMediaRequestData: MediaRequestData get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) @@ -46,6 +47,7 @@ sealed interface MediaItem { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val blurHash: String?, ) : Event { val thumbnailMediaRequestData: MediaRequestData get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) @@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? { is MediaItem.Voice -> null } } + +fun MediaItem.Event.blurHash(): String? { + return when (this) { + is MediaItem.Image -> blurHash + is MediaItem.Video -> blurHash + else -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt index be737878284..96059cfd1f2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt @@ -34,6 +34,7 @@ fun aMediaItemImage( ), mediaSource = MediaSource(mediaSourceUrl), thumbnailSource = null, + blurHash = null, ) } @@ -50,6 +51,7 @@ fun aMediaItemVideo( ), mediaSource = mediaSource, thumbnailSource = null, + blurHash = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt index 6809f6b6856..e568f375174 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 @@ -79,6 +79,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when { mediaInfo = mediaInfo, mediaSource = mediaSource, thumbnailSource = thumbnailSource, + blurHash = blurHash, ) } mediaInfo.mimeType.isMimeTypeVideo() -> { @@ -88,6 +89,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when { mediaInfo = mediaInfo, mediaSource = mediaSource, thumbnailSource = thumbnailSource, + blurHash = blurHash, ) } mediaInfo.mimeType.isMimeTypeAudio() -> { From ced7137f78f77e373e4bdc3b433acc91d932b07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 09:44:00 +0200 Subject: [PATCH 15/27] Move `ContentScannerService` to be injected in `RoomScope` to match `EventContentValidationCache` --- .../features/contentscanner/impl/di/ContentScannerModule.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index bec545c00f5..8a4f48d1c86 100644 --- 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 @@ -13,17 +13,17 @@ 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.di.SessionScope +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import kotlinx.coroutines.CoroutineScope @BindingContainer -@ContributesTo(SessionScope::class) +@ContributesTo(RoomScope::class) object ContentScannerModule { @Provides - @SingleIn(SessionScope::class) + @SingleIn(RoomScope::class) fun providesContentScannerService( contentScanner: ContentScanner?, eventContentValidationCache: EventContentValidationCache, From 421ce9c6cf8f8be53ec6185ca36691dc16600a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 09:46:37 +0200 Subject: [PATCH 16/27] Fix lint issues --- .../contentscanner/impl/DefaultContentScannerService.kt | 3 ++- .../impl/timeline/components/event/TimelineItemAudioView.kt | 2 +- .../impl/timeline/components/event/TimelineItemImageView.kt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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 index 7ec037913e5..6906bf5c430 100644 --- 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 @@ -69,7 +69,8 @@ class DefaultContentScannerService( updateState(AsyncData.Success(it)) cachedValue?.addHandledSource(url) - }.onFailure { exception -> + } + .onFailure { exception -> if (exception is IOException) { // If it's an IO-related exception, we can retry later, so we don't cache it updateState(AsyncData.Uninitialized) 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 52aa1e9e616..42366283977 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 @@ -24,7 +24,7 @@ fun TimelineItemAudioView( content: TimelineItemAudioContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember {ContentValidationState() }, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Audio(), 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 1aea58d7e98..321d617eb5e 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 @@ -74,9 +74,9 @@ fun TimelineItemImageView( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onShowContentClick: () -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, contentValidationState: ContentValidationState = remember { ContentValidationState() }, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, ) { val a11yLabel = stringResource(CommonStrings.common_image) val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel From a9ae353c351be5e642161004629f1dd7ce7748e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 10:57:48 +0200 Subject: [PATCH 17/27] Fix tests --- .../contentscanner/api/ContentScannerService.kt | 2 +- .../components/event/TimelineItemTextView.kt | 4 ++++ .../components/layout/ContentAvoidingLayout.kt | 12 ++++++++++++ .../messages/impl/timeline/TimelinePresenterTest.kt | 5 +++++ .../messages/impl/timeline/TimelineViewTest.kt | 12 ++++++------ .../protection/TimelineProtectionPresenterTest.kt | 6 ++++++ .../impl/DefaultMediaViewerEntryPointTest.kt | 1 + .../impl/datasource/EventItemFactoryTest.kt | 3 +++ .../datasource/TimelineMediaGalleryDataSourceTest.kt | 1 + .../impl/viewer/MediaViewerPresenterTest.kt | 1 + .../impl/viewer/SingleMediaGalleryDataSourceTest.kt | 3 +++ 11 files changed, 43 insertions(+), 7 deletions(-) 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 index a417cb8a8d1..10242a2ceb7 100644 --- 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 @@ -17,6 +17,6 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner * * The default implementation does nothing, unless a [ContentScanner] is provided when building the client. */ -interface ContentScannerService { +fun interface ContentScannerService { fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 0449ef26f0c..1a4be5d2e57 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis import io.element.android.libraries.androidutils.text.LinkifyHelper import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.wysiwyg.compose.EditorStyledText @@ -43,6 +44,9 @@ fun TimelineItemTextView( modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { + // The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode. + if (LocalUiTestMode.current) return + val emojiOnly = content.formattedBody.toString() == content.body && content.body.replace(" ", "").containsOnlyEmojis() val textStyle = when { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt index a299d2f1ced..cebe7f93b27 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.layout import android.text.Layout +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.wysiwyg.compose.EditorStyledText import kotlin.math.max import kotlin.math.min @@ -41,6 +43,7 @@ import kotlin.math.roundToInt * @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`. * @param content The 'content' component of the layout. */ +@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't @Composable fun ContentAvoidingLayout( overlay: @Composable () -> Unit, @@ -52,6 +55,15 @@ fun ContentAvoidingLayout( ) { val scope = remember { ContentAvoidingLayoutScopeInstance() } + // Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode. + if (LocalUiTestMode.current) { + Column { + scope.content() + overlay() + } + return + } + Layout( modifier = modifier, content = { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index f8f23b35f10..4c5865525d4 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 @@ -29,6 +31,7 @@ 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.featureflag.api.FeatureFlags +import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -1562,6 +1565,7 @@ class TimelinePresenterTest { featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, + timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1582,6 +1586,7 @@ class TimelinePresenterTest { analyticsService = FakeAnalyticsService(), liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, + timelineProtectionPresenter = timelineProtectionPresenter, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index dc63817e99e..6348d9b1155 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState @@ -67,7 +67,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), eventSink = eventsRecorder, ), ) @@ -79,7 +79,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = true, eventSink = eventsRecorder, ), @@ -98,7 +98,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = false, eventSink = eventsRecorder, ), @@ -133,7 +133,7 @@ class TimelineViewTest : RobolectricTest() { timelineItems = persistentListOf( aTimelineItemEvent( // Do not use a Text because EditorStyledText cannot be used in UI test. - content = aTimelineItemImageContent(), + content = aTimelineItemTextContent(), messageShield = MessageShield.UnverifiedIdentity(true), ), ), @@ -155,7 +155,7 @@ class TimelineViewTest : RobolectricTest() { val eventsRecorder = EventsRecorder() setTimelineView( state = aTimelineState( - timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), + timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())), isLive = false, eventSink = eventsRecorder, messageShield = aCriticalShield(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index d6af8ebd680..0f3e8cb2e5d 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,9 +9,13 @@ 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.architecture.AsyncData +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 @@ -90,8 +94,10 @@ class TimelineProtectionPresenterTest { private fun createPresenter( room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), + contentScanner: ContentScannerService = ContentScannerService { _: EventId, _: MediaSource, _: (AsyncData) -> Unit -> }, ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, + contentScannerService = contentScanner, ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 9cd134b843a..0c2e36903c3 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest { avatarInfo = AvatarInfo(filename = "avatar.png"), mediaSource = MediaSource(url = "avatarUrl"), thumbnailSource = null, + blurHash = null, ) ) assertThat(result.plugins).contains(callback) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index 7d0018f1207..518e808579b 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, ) ) } @@ -744,6 +746,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource(""), thumbnailSource = null, + blurHash = null, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt index 88aefb842cb..a5151f3b68a 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt @@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest { ), mediaSource = MediaSource("url"), thumbnailSource = MediaSource("url_thumbnail"), + blurHash = A_BLUR_HASH, ) ), fileItems = persistentListOf() diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index f917e8e0d1c..bdf4e352d68 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 @@ -983,4 +983,5 @@ internal fun createMediaViewerEntryPointParams( mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, + blurHash = null, ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt index a8696909079..ad44d1b372f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt @@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = params.mediaInfo, mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = null, ) } ) @@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = params.mediaInfo, mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = null, ) } ) @@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest { mediaInfo = mediaInfo, mediaSource = aMediaSource(url = "aUrl"), thumbnailSource = aMediaSource(url = "aThumbnailUrl"), + blurHash = null, ) } From aae38acf98b1a30e0840dfee75621fdc15a643b3 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 7 Jul 2026 12:11:05 +0000 Subject: [PATCH 18/27] Update screenshots --- ...ui.media.contentvalidation_InvalidContentView_Day_0_en.png | 3 +++ ....media.contentvalidation_InvalidContentView_Night_0_en.png | 3 +++ ...atrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png | 3 +++ ...rix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png | 3 +++ .../libraries.textcomposer_ComposerModeView_Day_1_en.png | 4 ++-- .../libraries.textcomposer_ComposerModeView_Day_2_en.png | 4 ++-- .../libraries.textcomposer_ComposerModeView_Day_3_en.png | 4 ++-- .../libraries.textcomposer_ComposerModeView_Night_1_en.png | 4 ++-- .../libraries.textcomposer_ComposerModeView_Night_2_en.png | 4 ++-- .../libraries.textcomposer_ComposerModeView_Night_3_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png | 4 ++-- ...s.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png | 4 ++-- ...s.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png | 4 ++-- ...textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png | 4 ++-- ...textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_0_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_10_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_11_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_1_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_2_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_3_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_4_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_5_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_6_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_7_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_9_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_0_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_10_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_11_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_1_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_2_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_3_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_4_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_5_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_6_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_7_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_9_en.png | 4 ++-- ...ies.textcomposer_TextComposerScaledDensityWithReply_en.png | 4 ++-- 59 files changed, 122 insertions(+), 110 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png new file mode 100644 index 00000000000..d0356842774 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e99e5407e785bec42ab92c966ca2e76810f3263968618ca7a0f7438d3ca8b7b3 +size 14144 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png new file mode 100644 index 00000000000..492d3d25f90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media.contentvalidation_InvalidContentView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee69ac6b6c9b0f436efadb8a843d553b434622b911296e2148427e392c975ce3 +size 13574 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png new file mode 100644 index 00000000000..980acf80096 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb9565278a98b2b785124ff810c6b8fec733883edf94a1f5cce51d3385e3fa5c +size 14163 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png new file mode 100644 index 00000000000..a24b5c05a96 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_ReplyToInvalidContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10aa3fecd1ddbdfd45c76dd25629485a8106ef0a67bc97e2f545602af7a957fd +size 13525 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png index 4caec798578..e5602cc9133 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5aa13d38c3c677dc552e1ad6ffdc19caa37c96287945b877804584e418e4acbc -size 10915 +oid sha256:c9e491bd12948a77b277e16594ce6aea80e7eddf0e2a2efb27e87546f156525b +size 10909 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png index 6482d7322b0..bdf129ee4aa 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:416b0ee79cd8c07fed42739309059895f8c36c20d3838be4a5a4afbfbd0b5394 -size 18548 +oid sha256:63f992f8496bac188c746dfc000f3d62d9f88c08131110d5977159b72d61169a +size 19669 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png index 6f42ac691eb..679685e9364 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c82fdba61809fce17e2eb7e9a79857a260e5598a2a9db88f481db69e2c7f79fa -size 7745 +oid sha256:ee086e521812765d474d094157c0311f48e2a3feff3bf6756be06974114c020f +size 7734 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png index fddd092e487..e26ea57b895 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36aad99f720579ad309c01591f2d4e765925aefd60bec2f66a28c14d5748b855 -size 10551 +oid sha256:4532dceab59523019eb7e0ca944c3c50278ef91eea6d6dc194bda743b9511fdf +size 10528 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png index 92b79113e1a..60a8334d917 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d527056f8cc0ae149aed408898cfc47cfdaba91cc7367e4cbf10fa49df44f9b -size 17781 +oid sha256:f644d1b6798c3761b0a16924a697cc653f31a090bab723177e40ec48131789a3 +size 19008 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png index f59422b33e7..9ddb94878dc 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_ComposerModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9042bb2175fb555c005faa1f69bffd1640c91a3c109a18d4759d802d1540350 -size 7485 +oid sha256:bd1cd65b9fa83f0dc89331eee859ce08a3ac9444c815b4ddc572fd2953d38085 +size 7459 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png index 4a6e6655005..bdd255116a0 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ead7c23c723cbaf116629cfc502f4696454a1da1bdcb4aeaac29605d937d2605 -size 71610 +oid sha256:01d65375670bd50fdd58c2b105fecae6efb572df1a23e4d883361fba4ebd32af +size 71628 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png index 3ee771684aa..e3d68e573f4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e9d1d7021b65b18e7038e6551495b6d391381e85484827c005594fef3a038c7 -size 57984 +oid sha256:b0477ab88d07880dfef518acbeb6a020122797b4d1833bdf58f7a9a4342d93ad +size 58002 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png index 846a262c57a..1c308dc42cb 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68ef756dd45911d9a06f5ceb2ce4917d0d35ed0c9f1b6aa559fccef662672e38 -size 71118 +oid sha256:dd47b5f29485ade1aa9bbf1ef9f13b0be0b829f4b60f047bcdd616daa5044925 +size 71141 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png index c56359f7e45..e5feb7324da 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63eb5bc48de34efb71ffa644e78f18e0596c709f153b35fb6b7aec49dbdaab78 -size 79800 +oid sha256:1affca1d77e8929fe3abe279ae3bde06856313ef8dbcbe335905d50afc6493ac +size 81821 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png index 4551d86254c..7df51fb2557 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe419a00799214722644f17edb94aa4dda3bb7c3b4f19dad689c490bf05d550d -size 60676 +oid sha256:658ff95ae679d89fcb1b093617a46f3555bf242d44781a15c2a34c9f5a1a0c81 +size 60697 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png index 0beaf993fbd..b566c6f4491 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:864b6a01fbcd041a2cb5fee1602fceed5f311a1d73bab6fb7b10a3acf279f47f -size 59579 +oid sha256:5238f56c1cf3437ea6a8cae67497c3a80acdfd0842e6ab970bf398b422b40ba6 +size 59601 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png index 326f3948eda..acd556e96e1 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59a644589a3eb4ef7d791994caf7c6aae6b23a8964405db4d8e9ebba4fc26459 -size 66594 +oid sha256:de7995760d329c8425771d2d2fe461b817ae9b9e305275ca379cf95c1997d583 +size 66622 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png index f583680c902..a65ca8b35c6 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7185c0bbb80d40e3da31df878f5127947e5944dcf3ed81b8266342dea94a585d -size 88177 +oid sha256:4cf390d9a439b6df56e0ba58bef71fb7cc7abd4cd0a96f06307ea8d169ea9709 +size 88195 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png index 2de8f415758..cec8a0b12ac 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:957b3b1896978c725e03d8d8a22afb767fe032b8c10e499a70f0395eface7d12 -size 58909 +oid sha256:4112021940576375c6c32c2a5fb04f165368cfc7eb6ebdd2abdb3aabeb1cb707 +size 58930 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png index f97044e8b61..26bbc77e27b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:faa3cf430f066965686bf11dc9dcedd7a7161e18f6a96be566b8f9be615ee164 -size 58974 +oid sha256:91daf1d41634ff04960ca66a0acfcf2a337dd8e470c7d1f8f4aeea5df11685ab +size 58996 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png index a2a9d84ecae..a2bc71b494b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f21ce912bf7ec69db5cbb024c09d385cee117e0698b28432a54fb3c330d4b44c -size 66110 +oid sha256:236a64de0e67f91d2e3bc7e7442f99fd83f472a4be46c5f449d2e808ce4b6c43 +size 66132 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png index cdd4983e85f..738e779bad3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47521fcbb94b38913cdffa41faef1a0af40289576a892df84fb166b855a77cec -size 58441 +oid sha256:f7b9c8b0404e420411f22cab257c707ad1b0b50d9c42a35ff1253bcf753fd527 +size 58466 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png index f89f4b12b73..5236db62bf9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82904fd6abba5122b5b243e88e58bbf9a5c128128999afb4b536e689f114b967 -size 68610 +oid sha256:9e664f8e6f1e135c0243001e46e41a38ad72a9628c4960445f334ef87498715d +size 68511 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png index 4ccf9c59173..bc81f67b2be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06e63017d3636a943f015652d90089c6ded569930067dd8d6020df4053d98d3c -size 55340 +oid sha256:0043d71f6d17e9aa6392ff4ef28367e4f3cc9a5c637254b72adfd9c72cbfe3ad +size 55233 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png index aebee6a41b5..fc16867249d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4564c1019c830ef0bbed284bd9c8ac8dd1c8082dc76f89e394ae82a1756fa17 -size 68119 +oid sha256:0e24b826fa1fa408e6820078f0bb47fede7cf5d2a2bfae55978739e47084c249 +size 68028 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png index 42f7ea79a33..08e85044492 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:713af5b3d329a98e75e9587b8b228e1c017a342dc5bc4663df5d0aebf4951637 -size 76948 +oid sha256:15120e28f72ffbc1b035509b747d0137d56b6b0520341baad7f8ba090fb700b9 +size 78666 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png index 7313e32cf01..df3b32b8d2a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bf814a838410e92a932373b39aa91deb53b18857055770ffa2c1687d6128e37 -size 57989 +oid sha256:a55a88cef50cbfd1357f51e572093a84e056d0a93a12bd33fb25ba5f370e4615 +size 57901 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png index a3726143262..d460968d64d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:728842933007c366c10e0293a22b0722bf2436c43e3ced69bb78838fef4e4aac -size 56944 +oid sha256:6531f685230484610a5e12fade57ad8266c431b5a19f7b3eee77c15b75c96e38 +size 56848 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png index 639cfeee1f8..6dc48a06304 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55c8b429bfc0d518cde9b16127adf05fb23a29f70df2572a6212c3f837f36133 -size 63826 +oid sha256:d193184265e4d575a34a6820bc77482a126aac0c43cfd0bc09152906ef6fa601 +size 63732 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png index bdf1ea58853..d0b1d953ff5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb8e8cf4624d8b6a75db331dd43fb4b51df2723f98eccdc4d87bc1d4df80c3a8 -size 84918 +oid sha256:62e37482c9ace9e4332df8aa650d12c7300a2cab05194b825184dc39dd926772 +size 84860 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png index 465d5a5c112..c86ff3c07e2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c3c802c57eeb15b14ab02677a84b422238aa08515b9151d9e01c8aefc0327b5 -size 56322 +oid sha256:6c5edf80cef291b62f4c3f125e3c2e1073e5e6db40de4b826f83aece9ff55af8 +size 56221 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png index 3ba841381fd..dab20f0c93e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7995253a70c47aca2215d475549c5da8a5ecd6331f8e1d2268df6979cd5fbc01 -size 56276 +oid sha256:5787b8a8b31173fae71bd513d84d02467d154917d4a2d2825424ca868c8bd101 +size 56171 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png index ee775246b6b..523ffd0d505 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3868e61ecafeb234c4e3438c138ac420192a569949fc8f4f8e4c946ba42f14 -size 63243 +oid sha256:acf87ea3c8f6db07c1758861d3e4849295538c741393fda18759d7f66225cea0 +size 63155 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png index 861384a18ea..8b1d78f6015 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1912b608b35257618b42d8abb602f031b67fcbc9810e59e6b20bc86075002b5d -size 55847 +oid sha256:25f1ebd9f70c477df9b1159fd92becf0d5ec9139c36c081faa6af862b388e42e +size 55735 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png index b78d36fdd42..a7d1026e416 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:745714bf11601a9abedc9526d3ddfcbfebc1ed8d73872ebd33f10cc638119c19 -size 72647 +oid sha256:3ac4707a32f7680319d1026d9dd6f56de74a95c4a4f990b0c1adf5280fa1a1c7 +size 72582 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png index 5ef7a3ecaa7..319cf76ebc8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a55cd98bc72c250706402bfac0706da3adc60a23010ab3a634290f2115f8543 -size 56527 +oid sha256:621b28e55b2a2a6894c4eed975c4aafccf25e77ece0db90621e639c9718adbfb +size 56478 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png index 94488a81183..be729307b01 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f4ec0a26c52319ec4c0e3468115c977bf3d4be116fa29d5f234758978b518cc -size 70973 +oid sha256:1f56ca941457d2e53a3659e516a7a27da56071820b221c251ff89a231185320a +size 70911 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png index 31cf97c9c44..568ba4e7b77 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a34d90d08ce5cd10a239c3accb0a92abeeb5309ac73a079700ded09ed1ee1697 -size 82282 +oid sha256:c718cd9d08c6ff483a2567ff6795803574a873091c5a1b984a08e1873e691c65 +size 84530 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png index 7cb03f9aac6..9a40df428fa 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d2c24321d4e7e827aa56ec9715d591b0f47091dab02b13c1dc045a175a70154 -size 59640 +oid sha256:7aa00fb0b59a871158c44cb67a4536aa2318e215add4fabf221726406e9f2781 +size 59617 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png index e008b3ee4e2..a55b92e8b8a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7727ad7b23e98365dbf7b30f2375500cdbff8062d17a1909b166040e07281a0 -size 58638 +oid sha256:3f4d668e7f9fa7d294cb1de945dd70bfd1b394f14159705ad37c0f55cae2c6e7 +size 58611 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png index ba46e2e270a..35f2d39b793 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b514f0f3c246531c3bab262d9b85f60eb737b960c0f1401c0c8d684d75a4f1d9 -size 66694 +oid sha256:22d2ab3ba2f9e04510e7ea7d185baaf305d12a3548956a695fd72c3d7e181d57 +size 66671 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png index 0c232804432..437e1acae5f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a84a52cd5320adfa2bc77781d7d5b996b21a4dcc94d2800c66e636a06f40a40 -size 101578 +oid sha256:890b7338df518463862560114113d64504383c01c2e6db6800c85c082637f496 +size 101548 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png index 5b5b142b81d..7562d7090bd 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:795ab6fd6386131a124707e96d38a916dca88fa081562cbaef269f6831fc55ca -size 57855 +oid sha256:5c30ee7afcc5cb4c1a405a026a4ce3b36e645b5b720b39531c1b8b979e6961ec +size 57801 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png index 9de8ba3f72f..883800c68f3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cae668f5a1adfbe42e44ea472dbf7dc5079ec1c3826507859d3e0536766fdb39 -size 57700 +oid sha256:1f1e64891abde888d1ad06bfb24ad4ba1aafcc2eb7c9a573a1deb116761b4080 +size 57688 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png index e7784793c45..a5ed775376a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e704bc61a2ae26cfdc30262a4e43122f24faa2a546ae7f16ad15560fc615327 -size 66960 +oid sha256:dc00781c5eafbbc1f574506b7948eb64d9784f6c69f8c0e29be5d120f39b366d +size 66942 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png index 5b56c20157c..4eac81e3047 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:919a9159a4a6b82d65e2f0a59cf55031620f32fe4066aca9440ffdd9f97ccd01 -size 57246 +oid sha256:be145b0f36fb88eb76a15d90ebdd2edf89ec99c84112216ffdfd2bdae9a850af +size 57204 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png index b18545b512a..06ba9eb8731 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:758cd0c11cb3d951b7c6db787bbaf66dc87252758c6bc9b36e0d1b479fd495e8 -size 69281 +oid sha256:9ddb88b02eaed6c732e11dd26e3f45506d037d4e113db171c9283a2fab4f9bd7 +size 69203 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png index 6ec2ca88f62..d9e77e9bd41 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56711e24449f15fa13b6147d1087180bc18f9a873eb0a96d19acfbaae66f74fa -size 53284 +oid sha256:1d456410474fb03e3a0bfcd2d737b2f0333e56d26c3eddd3515bcf8031a17d59 +size 53155 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png index 638136dddae..5c44befebb2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b598d4cb370923b7aa28287247c08131b773aee20467dd5d3ce95d0506e26ec6 -size 67706 +oid sha256:ebd0a8984dc9ced8ea84180862034c531bd2713dba6e35729d2686921a01b3d8 +size 67634 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png index ccab6d1cc62..f3f6ee2ee89 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d0300a4ba4a025b29e37cf01e29d9cd77ac411fe00642c64a9c14ff80ee4149 -size 79018 +oid sha256:eb3bb205e9cf382961dc003ee33248a3dc87089060dffaadee6d2af3c842dd1a +size 80952 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png index c2a4773ab40..a438c1fdfec 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3224a33cd9db2adf69479e8acfbaacaabd0d8ba00d4d862f1b38fc8e360539bf -size 56546 +oid sha256:ee8618bb0da86007b0e50f957f7d941bd5f31332a021e36e7641f95d32dc2108 +size 56440 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png index a01a166e951..132c4d7b5ff 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2cbbf5adf4d45fa65aa176b55216f8dff89114ef1a5b4de57bc86a53a22f012 -size 55584 +oid sha256:b912c22474b75a78b951a071b9217e1fc25e2f2d49916a4724399fb898c4a4ef +size 55462 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png index d9592ab2d05..9da26c1c14c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49ee949c20152ff3c9ecef1bd361b223fc4eff62a420740d9b708343f5c6121 -size 63442 +oid sha256:99e13306414846ce38775a5b681562f6e79d2e8d860310e4dda1d1fb3aaac60f +size 63341 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png index e6f0772abc4..dab02cddf24 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46c32f65a5071767b8603f93711f8c3c6150bfc9f6c0f6fd61317aafdebb2e83 -size 97701 +oid sha256:92d0ab372ab8efb76da83c9e28fb5f2d6a4d202285f9fe0644d378d02f8e11ba +size 97602 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png index f18f28fa0f7..e9c013be927 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2baad13bfc9fd755108365a9b0f7251f60cc8f9bf335115ab5c02b2d0f19f74 -size 54660 +oid sha256:1579ecd04cfde9b62d85287014e519fbb04745d9253140e2cd9936f4a23dbe56 +size 54540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png index 381119052c9..9e0186982de 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a735f4a7003f631c230c29e4248d186d86111ada2c4c4cfc8e6e4fe9897b224 -size 54424 +oid sha256:e11143794228c270b85db674577948d52161fe1c16c8ae8ff04870636ee58e4d +size 54317 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png index 16da419a33a..a53692356be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c109f03989aaa14873823c230bb262add95099b44031512ee29b9f8c8e7d180f -size 63642 +oid sha256:8c51275bdbf340dafbeac16ba6c946c5ea8fbd829a8b1ad45f9c2d1da0a28df9 +size 63540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png index dfa13081114..41f97a01551 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbe7caa3bd8b8788befae4c4febbcd1bb02bd2ea9d6feb00bffb962d0f2885d6 -size 54080 +oid sha256:a99c944c69b41ce89be07fb0c183ef0588b4b0540c747dd40a1135ffcf8d1216 +size 53962 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png index 3806b4217cb..cb6393e72be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerScaledDensityWithReply_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d35ab62460942f4f14edc87aab84f4d659b18763c4cf8a1c353f99085c1c164d -size 16727 +oid sha256:2508c7785b3dc7f2d2e0abd45c2556c8bbd9a3905179f1082fe66b296d7b4752 +size 17282 From 45a136240e244b5d68d8d6f4e4a772ddb72eb028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 16:25:24 +0200 Subject: [PATCH 19/27] Add new tests and improve screenshots --- .../event/TimelineItemEventContentView.kt | 3 +- .../components/event/TimelineItemFileView.kt | 49 ++++++++++++++++ .../components/event/TimelineItemImageView.kt | 31 ++++++++++ .../event/TimelineItemStickerView.kt | 57 +++++++++++++++++++ .../components/event/TimelineItemVideoView.kt | 56 ++++++++++++++---- .../components/event/TimelineItemVoiceView.kt | 50 ++++++++++++++-- .../event/TimelineItemVideoContentProvider.kt | 3 +- .../TimelineProtectionPresenterTest.kt | 42 +++++++++++++- .../InMemoryEventContentValidationCache.kt | 24 ++++++++ .../tests/konsist/KonsistPreviewTest.kt | 10 ++++ 10 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt 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 bd66381bb26..55bc1f1fcc1 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 @@ -130,6 +130,7 @@ fun TimelineItemEventContentView( onShowClick = onShowContentClick, modifier = modifier, contentValidationState = contentValidationState, + onContentLayoutChange = onContentLayoutChange, ) } is TimelineItemVideoContent -> { @@ -188,7 +189,7 @@ fun TimelineItemEventContentView( content = content, onContentLayoutChange = onContentLayoutChange, modifier = modifier, - isDangerousContent = contentValidationState.state.value.dataOrNull()?.not(), + contentValidationState = contentValidationState, ) } is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") 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 d3b67d21586..879e911a3bf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -9,16 +9,25 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings /** @@ -53,3 +62,43 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte ) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewScanningContentPreview() { + ElementTimelineItemPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemFileContent())) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewScanningContentFailedPreview() { + ElementTimelineItemPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to ContentValidationState(isValid = false) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemFileContent(caption = "A caption"))) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 321d617eb5e..729f550fd80 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 @@ -55,9 +55,12 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -202,6 +205,34 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon ) } +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemImageContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemImageContent(caption = "A caption")), + ) + } +} + @PreviewsDayNight @Composable internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index b67521b4d60..7b725c22c9b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,10 +27,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.architecture.AsyncData @@ -36,9 +44,14 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.exception.ClientException 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.InMemoryEventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @@ -51,6 +64,7 @@ fun TimelineItemStickerView( onLongClick: (() -> Unit)?, onShowClick: () -> Unit, modifier: Modifier = Modifier, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) @@ -72,6 +86,7 @@ fun TimelineItemStickerView( onShowClick = onShowClick, ) { var isThumbnailLoaded by remember { mutableStateOf(false) } + AsyncImage( modifier = Modifier .fillMaxSize() @@ -111,8 +126,22 @@ fun TimelineItemStickerView( }, ) } + + if (isContentBeingValidated) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } } } + } else { + InvalidContentView( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + // If no caption is present, we need to measure the last line of the text to avoid overlapping with the timestamp + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + ) } } @@ -127,3 +156,31 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke onShowClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemStickerContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewScanFailedContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemStickerContent()), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 3c791dc01f6..11b878d90d5 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 @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable @@ -61,12 +62,15 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -149,16 +153,20 @@ fun TimelineItemVideoView( }, ) - 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() } } } @@ -266,3 +274,31 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { ) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVideoContent()), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + } + CompositionLocalProvider(LocalEventContentValidationState provides cache) { + ATimelineItemEventRow( + event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVideoContent(caption = "A caption")), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 463299e36da..d5a21776989 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,10 +41,16 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview @@ -52,12 +59,17 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState import kotlinx.coroutines.delay @Composable @@ -66,9 +78,9 @@ fun TimelineItemVoiceView( content: TimelineItemVoiceContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - isDangerousContent: Boolean? = false, + contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { - if (isDangerousContent == true) { + if (contentValidationState.isInvalid()) { InvalidContentView( modifier = modifier, onTextLayout = ContentAvoidingLayout.measureLastTextLine( @@ -116,7 +128,7 @@ fun TimelineItemVoiceView( verticalAlignment = Alignment.CenterVertically, ) { if (!isTalkbackActive()) { - if (isDangerousContent == false) { + if (contentValidationState.isValid()) { when (state.buttonType) { VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) @@ -124,7 +136,7 @@ fun TimelineItemVoiceView( VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } - } else if (isDangerousContent == null) { + } else if (contentValidationState.state.value.isLoading()) { ProgressButton(displayImmediately = true) } } @@ -327,3 +339,33 @@ internal fun ProgressButtonPreview() = ElementPreview { ProgressButton(displayImmediately = false) } } + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview { + TimelineItemVoiceView( + state = aVoiceMessageState(), + content = aTimelineItemVoiceContent(), + onContentLayoutChange = {}, + contentValidationState = remember { ContentValidationState(mutableStateOf(AsyncData.Loading())) } + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewScanningContentFailedPreview() = ElementPreview { + val eventId = EventId("\$eventId") + val cache = remember { + InMemoryEventContentValidationCache( + initial = mapOf( + eventId to ContentValidationState(isValid = false) + ) + ) + } + CompositionLocalProvider( + LocalEventContentValidationState provides cache, + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + ATimelineItemEventRow(event = aTimelineItemEvent(eventId = eventId, content = aTimelineItemVoiceContent())) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index 0fadb6c9a60..62511fccc5f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -27,10 +27,11 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider) -> Unit -> + callback(AsyncData.Success(true)) + } + val presenter = createPresenter( + mediaPreviewService = mediaPreviewService, + room = room, + contentScannerService = contentScannerService, + ) + presenter.test { + val initialState = awaitItem() + val validationState = ContentValidationState() + assertThat(validationState.isValid()).isFalse() + + initialState.eventSink( + TimelineProtectionEvent.ValidateContent( + eventId = AN_EVENT_ID, + mediaSource = MediaSource("https://example.com/media"), + validationState = validationState + ) + ) + + runCurrent() + + contentScannerService.assertions().isCalledOnce() + assertThat(validationState.isValid()).isTrue() + } + } + private fun createPresenter( room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), - contentScanner: ContentScannerService = ContentScannerService { _: EventId, _: MediaSource, _: (AsyncData) -> Unit -> }, + contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: MediaSource, _: (AsyncData) -> Unit -> }, ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, - contentScannerService = contentScanner, + contentScannerService = contentScannerService, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt new file mode 100644 index 00000000000..9171cf85163 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/InMemoryEventContentValidationCache.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.contentvalidation + +import io.element.android.libraries.matrix.api.core.EventId + +class InMemoryEventContentValidationCache( + initial: Map = emptyMap(), +) : EventContentValidationCache { + private val cache = initial.toMutableMap() + + override operator fun get(eventId: EventId): ContentValidationState { + return cache[eventId] ?: ContentValidationState() + } + + fun put(eventId: EventId, state: ContentValidationState) { + cache[eventId] = state + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index fdb4e27ef06..060360c7d44 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -155,10 +155,20 @@ class KonsistPreviewTest { "TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithThreadSummaryPreview", + "TimelineItemFileViewScanningContentFailedPreview", + "TimelineItemFileViewScanningContentPreview", "TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemImageViewHideMediaContentPreview", + "TimelineItemImageViewScanningContentFailedPreview", + "TimelineItemImageViewScanningContentPreview", + "TimelineItemStickerViewScanFailedContentPreview", + "TimelineItemStickerViewScanningContentPreview", "TimelineItemVideoViewHideMediaContentPreview", + "TimelineItemVideoViewScanningContentFailedPreview", + "TimelineItemVideoViewScanningContentPreview", + "TimelineItemVoiceViewScanningContentFailedPreview", + "TimelineItemVoiceViewScanningContentPreview", "TimelineItemVoiceViewUnifiedPreview", "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", From f9a07a970e1273ce5977381c3e628222812768e0 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 7 Jul 2026 14:43:37 +0000 Subject: [PATCH 20/27] Update screenshots --- ...ent_TimelineItemFileViewScanningContentFailed_Day_0_en.png | 3 +++ ...t_TimelineItemFileViewScanningContentFailed_Night_0_en.png | 3 +++ ...nts.event_TimelineItemFileViewScanningContent_Day_0_en.png | 3 +++ ...s.event_TimelineItemFileViewScanningContent_Night_0_en.png | 3 +++ ...nt_TimelineItemImageViewScanningContentFailed_Day_0_en.png | 3 +++ ..._TimelineItemImageViewScanningContentFailed_Night_0_en.png | 3 +++ ...ts.event_TimelineItemImageViewScanningContent_Day_0_en.png | 3 +++ ....event_TimelineItemImageViewScanningContent_Night_0_en.png | 3 +++ ...vent_TimelineItemStickerViewScanFailedContent_Day_0_en.png | 3 +++ ...nt_TimelineItemStickerViewScanFailedContent_Night_0_en.png | 3 +++ ....event_TimelineItemStickerViewScanningContent_Day_0_en.png | 3 +++ ...vent_TimelineItemStickerViewScanningContent_Night_0_en.png | 3 +++ ...nt_TimelineItemVideoViewScanningContentFailed_Day_0_en.png | 3 +++ ..._TimelineItemVideoViewScanningContentFailed_Night_0_en.png | 3 +++ ...ts.event_TimelineItemVideoViewScanningContent_Day_0_en.png | 3 +++ ....event_TimelineItemVideoViewScanningContent_Night_0_en.png | 3 +++ ...meline.components.event_TimelineItemVideoView_Day_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_1_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_2_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_3_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_0_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_1_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_2_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_3_en.png | 4 ++-- ...nt_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png | 3 +++ ..._TimelineItemVoiceViewScanningContentFailed_Night_0_en.png | 3 +++ ...ts.event_TimelineItemVoiceViewScanningContent_Day_0_en.png | 3 +++ ....event_TimelineItemVoiceViewScanningContent_Night_0_en.png | 3 +++ ...components.event_TimelineItemVoiceViewUnified_Day_0_en.png | 4 ++-- ...mponents.event_TimelineItemVoiceViewUnified_Night_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_0_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_10_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_11_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_12_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_13_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_14_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_1_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_2_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_3_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_4_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_5_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_6_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_7_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_8_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_9_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_0_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_10_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_11_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_12_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_13_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_14_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_1_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_2_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_3_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_4_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_5_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_6_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_7_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_8_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_9_en.png | 4 ++-- ...omponents.event_TimelineVideoWithCaptionRow_Night_0_en.png | 4 ++-- 61 files changed, 142 insertions(+), 82 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..59c0bd6a553 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e86072c5d0feefeade772c475516c15745158263c25d1b1bc3508f67a9dc193 +size 24368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..21bba287aba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e16d3c966e886c87587daad3328a0c40706861c17da1e3d2991f8197e4e3057 +size 24544 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..ddbd585aa42 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:659b376f3030142fe61bd95e62c312b2272179fbed3d5fe07d9446c8d5b07594 +size 15423 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..3584a586310 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemFileViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a352a0d80264a2998ffe5c08665e752ef34b96a92430eb047badd786c0a8b7be +size 15754 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..62a7f9dc0c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee577c2edd4519bf435e39eded760f9b1dd19bf07e16057741b964f4d6453232 +size 23882 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..fc4f17b2198 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb080f17bcecb43ec808bae94be4e0faaf0668e9d448e3588d8d11f1b88de82 +size 24090 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..930a0ecf824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9888d8f9f8b3ded76f601c7300c33c8d111aa926ed7bc0793fe3212710786656 +size 119693 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..e52ec14d2e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemImageViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c90a542bf2c396ec844e1e8b1763a94078f0139cbfdd1cd8ec7ab7e4e21f2b91 +size 120861 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png new file mode 100644 index 00000000000..80fc2195e8d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:441535bf89e8e9362f9e39b024a0ff117c952310f621da2dce96682dc45a8cf8 +size 21234 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png new file mode 100644 index 00000000000..1fba202cab2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:195adb4e0b8049523485709ab801adbab64cc4eab1c9f3b96e0106f857cb985c +size 21486 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..318b30a06e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebb700f635d3315fa4201abdcb74d65cccbca13e827c39ae86211435a2950d5f +size 55865 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..52a471ddc4f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e98e96ceacb199ccc3922cd332032490ebd6074c9d7b94a64b095fe3b9d7cce +size 55793 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..62a7f9dc0c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee577c2edd4519bf435e39eded760f9b1dd19bf07e16057741b964f4d6453232 +size 23882 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..fc4f17b2198 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb080f17bcecb43ec808bae94be4e0faaf0668e9d448e3588d8d11f1b88de82 +size 24090 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..5176d81d926 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0266ca778ef09fdf62d306e485b90cf13aea8b0cab4ce0ae3dd2c096c9992171 +size 282455 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..86e2d847de4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2338265e5c5f7efb662bbe262e95b12fd719022a92652a2a3b4cf0336026c576 +size 281597 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png index 9238837c39e..9d1e883dc15 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:49f496a8609b89c8ff1837df4b2979d781907a1480918178f43c07f048a40583 +size 277030 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png index 861f05e267e..fb1be62b45d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 -size 506341 +oid sha256:404fb5170e377c87d7add204458137e93f21a9504a086aeb47e42530dc362948 +size 508620 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png index c48e8c45b6b..d27fa88138b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 -size 383712 +oid sha256:3d22564f849d029c6371e8e0ef7043162431dcfba21ac66ce1f565694b6afa1e +size 386342 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png index 9238837c39e..9d1e883dc15 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:49f496a8609b89c8ff1837df4b2979d781907a1480918178f43c07f048a40583 +size 277030 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png index 9238837c39e..5535e3b7f2f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:7f7c0aa212f2bc3e45d9fb9001d1b640ab0d115379826ac1479c4d560761d55a +size 277097 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png index 861f05e267e..c53486b179f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 -size 506341 +oid sha256:140e83bec7563ae749407d956f3d373c4ea2a74b563c067ba9eec9c58345e326 +size 508670 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png index c48e8c45b6b..1494f16accd 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 -size 383712 +oid sha256:dc84f60f0f5e697abe249f60c200eaa9544d84e91cc9e5d32a3bdbb82d5a73ce +size 386387 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png index 9238837c39e..5535e3b7f2f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:7f7c0aa212f2bc3e45d9fb9001d1b640ab0d115379826ac1479c4d560761d55a +size 277097 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png new file mode 100644 index 00000000000..020e88d3753 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f45479b43f690785e764547c9370ca73320210200e78c0873f7d0bdae299a41c +size 21781 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png new file mode 100644 index 00000000000..9893d3cadff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContentFailed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:564bc123926c3c685310269c63409bc69d6423bd32f2ad8c222b73bed32d5f36 +size 21933 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png new file mode 100644 index 00000000000..09ecd0f3df5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:691b064d8dcdbc3197e9c8ed51d035aba438bb2d86853658ae22b1e7d5574a60 +size 5991 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png new file mode 100644 index 00000000000..81abea7f34a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewScanningContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a28425f9079a5d27a05744808b599a2b21b78f501bd84909d00729f7bb55528b +size 6030 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png index aa26b67e09c..d6a814bf230 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed553e5a7f4b923f686da6a4411529b801e0e78fa627b5ffdeecd6a161a72d9a -size 43844 +oid sha256:5682d9a10106f8f46cf91f69e93820ea37034c65e0b703b737c96fd34f1382b6 +size 41916 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png index aefb84eef1d..03634dcfc5b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f793e5e0c6d0c59271e91f2859de91258b12bb1be9ae070f5c83da4ba831d111 -size 45619 +oid sha256:4d2347b612d5b4b27fd08979a9d577c73533e1c700f7ff3ebe75c4826445edac +size 42623 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png index e5c8704b684..8d706582d04 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eeb01b15f9fe74f17b31013674d7f8ce52e485780431e0b52898a83141d4ef77 -size 5207 +oid sha256:7e4fb3b9d2d8ad8037ef22bb83fc6b088219c4340959535c17620f9e6cde4666 +size 5038 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png index 7d03d87f791..48fd9185d06 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67d30d074399af52222b50d08650c420cbb76c98840158cf7621e9eb55b6a81b -size 8368 +oid sha256:8cf2332b8aa0c96fb317fb026fdc4eecd7d00fe0c29c0b94990f07e3606a3783 +size 8570 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png index eba4c7f6e7d..17c195a8666 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe9f5514c4eaed79d712893cc37a8d63b9cb3deee2938469deba28fb5c9b3afa -size 8827 +oid sha256:f6c8d86a2fe8b006a62563ca79cff665c096b5f2fe0e5adb4b266669c04733cc +size 8743 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png index 2d250955b78..03593c8be06 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c17bcb5d8af655ed397c1a2f378a7e63ff7af4f5418ce23ed1083dadc2d5ebf -size 8465 +oid sha256:ce3c1b06c4d48a5f6cda97dc8a59b3d2c2686e783c2eb27d527c510b86297ae8 +size 8701 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png index 2f62ccfb229..5c2b5ab86d4 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62cb8d36c0b7ff29d19b76f6c8225beb60912cae3d58281f4fd60f9f37641e87 -size 8789 +oid sha256:77f69bf2e88293341ef6bde0a37db1fcf47a53f4f9f4ff99cd0bc16d3a9abdde +size 8994 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png index 6a70f3e1c70..73013740305 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812a88c57c59a68a394d8a35dc5d5ea88b4ba0ecf9845b57262260c30d1a245d -size 8836 +oid sha256:c2b5e606969e3721467e80cd50c5155a21f1b3539bebb23ea6797a3d3c90ee4b +size 8995 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png index a867f7175ee..ee008caa240 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7761e645c471bccca171bec10a91e483b8b980938dba794721b22261e5a92e6b -size 5459 +oid sha256:b56f8042f4fdd691c4cbfbcd8b78c403508e9e891c1d8875a3caf681c0beadd9 +size 4875 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png index 99a3aa27839..baad6948763 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb1ea225a0793930b51886c91c85ff5fc64f6d7ba5466099a644178edabc21c2 -size 5194 +oid sha256:0a5862175b39e1545678f40aaf22b8096073c2d830baf2d479ab624348770537 +size 4977 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png index 2b0c60caf1c..6f23fd6ec5c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7765de29d007d0e911bbae1350951d4628a733ce02e784c13607856df569ac9 -size 5406 +oid sha256:28e8f99576009e5ecd9f16c2392dc55d4c32395814d1726310445f98e03172aa +size 5236 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png index 8bdee15425c..5acd1940c6f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9be8e55d9d789d81ad9288f4a824495dcf17185be3ef55867b5272a91e0e7274 -size 5499 +oid sha256:49e123bd5943a6e24015433896b131badc02e820177869d882909e172cc5f739 +size 5285 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png index cdd1cce35df..2b6979fbca7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a76060c9b9b09997190685a03954964b251e95ac8a409e50b6a72e864b6680ed -size 5721 +oid sha256:9f91b7d0f786b2fdf153e65a2e102c71f6294bada41b95df0425f26583e878ef +size 5545 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png index f492623c13d..264974ea7cc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:098c2d444a17049aa6e2fab73c885f8575855e55c5c9949043a3dbb1176da523 -size 6009 +oid sha256:047395ca72dafa655ebc7e1b84bb399529a500199314e0ff66015bc349eb6e6f +size 5428 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png index fd679ed1802..0d2b911076b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e6dc10827ccedcf99b5c0baeab7f35cbf79320c3993668d618b7669d10acd8c -size 5711 +oid sha256:6cee09dcb3ad9366b4633860285549ccd1713e6d98507f52ffd5013dd8d384c9 +size 5497 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png index 259f1f84056..99e0d3fa47e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:312c5c262860a15bcdcd60023acb220a3bcd6b552cb681b8d1fad97795f4be89 -size 5959 +oid sha256:1a2423be28975edc05e2c56413c59eb8cb010ced6614e1a5d6d633adf90fdd69 +size 5774 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png index 39b0a5fdb59..a6a21cef1ef 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb4e8c1a8a38f0fa4b2bd0b613f6383fa2624af0cfed003404e906afaf8fbdd -size 6098 +oid sha256:d5a4252643772938927b16b971d5c42f7f464bd546369c3facf2f529636edafa +size 5837 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png index 82cd02645fb..5cb98963a60 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ddf7e8dbc365a97484e409128b17d805014019ef7ba4689d1748754b172fe24 -size 5276 +oid sha256:277a0c0727b356bc26a67a393104a65c9ab189b1a3429102fa825b1cd74c90aa +size 5045 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png index 4e50229d946..18675b534a2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46d66244a5089bc9ef0978775feac606c4dcc885d7b4e28209c240a596b945c0 -size 8147 +oid sha256:f956d02e8e7e0f395e3c8208738ff78fc17241441ef537c49350ff08d5a07e3d +size 8274 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png index c18752a0ccb..78caad8dbe0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5610c7e73335e38e7dfb6383342bd2ef77d4b703c39d616bb32865aa049c1472 -size 8739 +oid sha256:287dc73fe7edd7910acc9af2bd7d2e6b9b5cb331d57aab120ea0ed781d70bd66 +size 8479 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png index e0938b70d46..d97e9f5923e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a35532200a8d13a500b4e855b5232cb51333ef7cc21d2aea753d4e4e72a55e15 -size 8401 +oid sha256:1f97efaae6da5b61a450ca5203305bf527b18585a1c16580800771aaf3c4f785 +size 8565 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png index f1b58239f99..3b4a953d8b8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2a70fac799f703627fcad12144ab5be0a329f66b1ee8b3b701a1fbc0a4b0d01 -size 8498 +oid sha256:9b9a4ec52afe29ce64afd43d57121960f1a447c98160f3f23ae916d17064cd46 +size 8611 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png index bfa594a0ee0..2dec80699b8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c30d37bd3d271749b7a1e2797734252c207c856ffe26c067d85765dd1e0208da -size 8505 +oid sha256:96f3613893288888180e82791ba32aba6ded720011651ede443d75fb7c27e0c8 +size 8615 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png index 41174daf225..411764eb933 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:055d6cc80406eae235df8963e3f4d1297c3289e15e98c39110eabc608bd52edf -size 5537 +oid sha256:0fce09b71f1bdf430e4ece8901e6362a8d7e226b8e60bcc18e1ef55000ad0da9 +size 4869 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png index cc606fbff81..552e3f36eb2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd9647075ce3c319a3314c1a2ee65e5cb25e4c5ce2ccf468f740ce6be77b73c1 -size 5292 +oid sha256:e80046f10f3439630343725aa7ecc867bc1a421f49fd54011d2380d519de607d +size 5004 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png index 4cbe91bbca6..9b1489406f4 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f365b2e728e35938bad04af6d559c4116e0df2acac68965d7e893108379c7e86 -size 5525 +oid sha256:150ee262a0b7d06b39328addce5bf1c10a64a2477a583dc47804c59be515209a +size 5218 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png index e2973bc33d0..fab507d43ce 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a5cfac43245635159a5ac31b65a4b8b1da26a4f8228b0b7f84ab0849975bdef -size 5559 +oid sha256:7c77636d0ecc552d5ec06372835679e6ed4af75a51c8e76940df867899d64872 +size 5248 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png index 993e4db9c62..114907d5e77 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fd1b21bb8111004d192f64e16204465e1ada5b03a92d7727b2fbcd45789a634 -size 5792 +oid sha256:6c38829db94f323aa5f22bda82a3ea9805c249659c3ab691ddbbfb7cdca22226 +size 5561 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png index 9347ba6ebc3..665b1e285ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67f62cd7e6d63a03fe4a0c5a10fe0444dc4dc941bdf363172ca3a464d3964443 -size 6092 +oid sha256:d23db38825014c1c84dfa0616a6f8e41e60331a7e1fa7d4cda10504780add378 +size 5439 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png index 5277d0d9c08..a81d629580b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c82921ec225d5e667e6419f16b13594b59d180832b279433a7c272f955581df9 -size 5777 +oid sha256:73028e20dbd8c090887a63c98373cf06041d38ecd2680711bbfff71ca2288234 +size 5524 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png index e31e556db16..3d9a261fcea 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b9bf0915a171539260f552ef4788f8107a211fa5cbbf1f2ce43c0d79836881f -size 6026 +oid sha256:0721ec5ad0b074e539f90c025cfe210f14313f89b0fd462820c0e9a61c923dba +size 5738 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png index 0234b1d6573..fdff6a63e37 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18c36a2e97244d24df454a6364f2db78fb1e8039693d536d53a79bb98134f882 -size 6134 +oid sha256:916d9b4af8143da8383e46024ec8249afa3c88a4bc141403fe9ee64145513b43 +size 5821 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 index 18319e718fb..d20bfd93cf0 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74 -size 606823 +oid sha256:9c4339bfe7cc578ac0285e86c38327dc3b2e55de1c9d56817c0574804febec65 +size 611864 From 82316bf6c7a28d00ac7d02ff18091b43a5421f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 17:20:12 +0200 Subject: [PATCH 21/27] Tweak `TimelineItemVoiceView` states --- .../impl/timeline/components/event/TimelineItemVoiceView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d5a21776989..9a76c0c4c25 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 @@ -136,7 +136,7 @@ fun TimelineItemVoiceView( VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } - } else if (contentValidationState.state.value.isLoading()) { + } else { ProgressButton(displayImmediately = true) } } From 5e69861f7f3a5c78fd224fe46d413d82dc6e7009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 17:22:35 +0200 Subject: [PATCH 22/27] Fix rendering of `TimelineItemVideoView` --- .../impl/timeline/components/event/TimelineItemVideoView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 11b878d90d5..5ca0fb5f6af 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 @@ -153,7 +153,7 @@ fun TimelineItemVideoView( }, ) - if (isContentBeingValidated) { + if (!isContentBeingValidated) { Box( modifier = Modifier.roundedBackground(), contentAlignment = Alignment.Center, From 86d30c44f9cb40314c35e65eba97e7eb34553dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 8 Jul 2026 10:15:12 +0200 Subject: [PATCH 23/27] Fix rebase issue --- .../android/features/messages/impl/MessagesFlowNode.kt | 2 ++ .../components/event/TimelineItemEventContentView.kt | 8 ++++++++ .../android/libraries/mediaviewer/api/GalleryItemData.kt | 1 + .../mediaviewer/impl/DefaultMediaViewerEntryPoint.kt | 1 - .../mediaviewer/impl/datasource/EventItemFactory.kt | 8 +++++--- .../mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt | 1 - .../impl/viewer/GalleryMediaGalleryDataSource.kt | 2 ++ .../impl/viewer/SingleMediaGalleryDataSource.kt | 1 + 8 files changed, 19 insertions(+), 5 deletions(-) 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 dd7a8b93690..88a90f6c7db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -831,6 +831,7 @@ class MessagesFlowNode( mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, type = galleryItem.type.toMediaViewerType(), + blurHash = galleryItem.blurhash, ) }.reversed() NavTarget.GalleryViewer( @@ -864,6 +865,7 @@ class MessagesFlowNode( mediaSource = attachment.mediaSource, thumbnailSource = attachment.thumbnailSource, type = GalleryItemData.Type.File, + blurHash = null, ) }.reversed() NavTarget.GalleryViewer( 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 55bc1f1fcc1..e0876aeaa22 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 @@ -156,6 +156,14 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) + is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = {}, + modifier = modifier, + ) is TimelineItemFileContent -> { TimelineItemFileView( content = content, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt index 3a2f2321db5..fb60a7488c9 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -18,6 +18,7 @@ data class GalleryItemData( val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val type: Type, + val blurHash: String?, ) : Parcelable { enum class Type { Image, diff --git a/libraries/mediaviewer/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 a9923254f48..4cbcd75a5ef 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -27,7 +27,6 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint { ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, - canShowInfo = false, // 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 1a14c2d4797..f489186ade1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -116,6 +116,7 @@ class EventItemFactory( ), mediaSource = c.source, thumbnailSource = c.info?.thumbnailSource, + blurHash = c.info?.blurhash, ) } is GalleryItemType.Video -> { @@ -136,6 +137,7 @@ class EventItemFactory( ), mediaSource = c.source, thumbnailSource = c.info?.thumbnailSource, + blurHash = c.info?.blurhash, ) } is GalleryItemType.Audio -> { @@ -244,7 +246,7 @@ class EventItemFactory( thumbnailSource = type.info?.thumbnailSource, blurHash = type.info?.blurhash, )) - is StickerMessageType -> MediaItem.Image( + is StickerMessageType -> listOf(MediaItem.Image( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( @@ -267,7 +269,7 @@ class EventItemFactory( thumbnailSource = type.info?.thumbnailSource, blurHash = type.info?.blurhash, )) - is VideoMessageType -> MediaItem.Video( + is VideoMessageType -> listOf(MediaItem.Video( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( @@ -290,7 +292,7 @@ class EventItemFactory( thumbnailSource = type.info?.thumbnailSource, blurHash = type.info?.blurhash, )) - is VoiceMessageType -> MediaItem.Voice( + is VoiceMessageType -> listOf(MediaItem.Voice( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( 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 3adddd2fd89..957f0011d3f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt @@ -137,7 +137,6 @@ class MediaGalleryFlowNode( mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, - canShowInfo = true, blurHash = navTarget.blurHash, ), callback = callback, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index 2dfb11a89f7..d254985ebfc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource( mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, + blurHash = galleryItem.blurHash, ) GalleryItemData.Type.Audio -> MediaItem.Audio( id = id, @@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource( mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, + blurHash = galleryItem.blurHash, ) } mixedItems.add(mediaItem) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt index e568f375174..f347e8181f9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt @@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource( ), mediaSource = params.mediaSource, thumbnailSource = params.thumbnailSource, + blurHash = params.blurHash, ) ), fileItems = persistentListOf(), From 68079e4793aad7300c5ebe332e54dd6ac0e68686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 8 Jul 2026 16:24:18 +0200 Subject: [PATCH 24/27] Add support for gallery events too - refactored `ContentValidationState` --- .../api/ContentScannerService.kt | 4 +- .../impl/DefaultContentScannerService.kt | 79 ++++------------ .../impl/di/ContentScannerModule.kt | 8 +- .../impl/di/NoopContentScannerService.kt | 10 ++- .../features/messages/impl/MessagesView.kt | 3 +- .../MessageComposerPresenter.kt | 12 +++ .../messages/impl/timeline/TimelineEvent.kt | 2 +- .../impl/timeline/TimelinePresenter.kt | 2 +- .../timeline/components/MessageEventBubble.kt | 13 +-- .../components/TimelineItemEventRow.kt | 52 ++++++++--- .../event/TimelineItemAttachmentView.kt | 32 ++++--- .../event/TimelineItemAttachmentsListView.kt | 85 ++++++++++++++++-- .../event/TimelineItemEventContentView.kt | 22 +++-- .../components/event/TimelineItemFileView.kt | 7 +- .../event/TimelineItemGalleryView.kt | 58 +++++++++++- .../components/event/TimelineItemImageView.kt | 31 ++++--- .../event/TimelineItemStickerView.kt | 31 ++++--- .../components/event/TimelineItemVideoView.kt | 31 ++++--- .../components/event/TimelineItemVoiceView.kt | 12 +-- .../model/event/TimelineItemEventContent.kt | 2 +- .../protection/TimelineProtectionEvent.kt | 2 +- .../protection/TimelineProtectionPresenter.kt | 7 +- .../timeline/util/AsyncImageStateHandler.kt | 45 ++++++++++ .../impl/timeline/TimelinePresenterTest.kt | 2 +- .../TimelineProtectionPresenterTest.kt | 16 ++-- .../api/timeline/item/event/EventContent.kt | 25 +++++- .../api/timeline/item/event/MessageType.kt | 8 ++ .../ContentValidationState.kt | 89 +++++++++++++------ .../contentvalidation/InvalidContentView.kt | 39 ++++++-- .../LocalEventContentValidationState.kt | 12 ++- .../matrix/ui/messages/reply/InReplyToView.kt | 17 ++-- .../textcomposer/ComposerModeView.kt | 21 ++--- 32 files changed, 530 insertions(+), 249 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt 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 index 10242a2ceb7..6333b4c156d 100644 --- 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 @@ -7,10 +7,10 @@ package io.element.android.features.contentscanner.api -import io.element.android.libraries.architecture.AsyncData 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. @@ -18,5 +18,5 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner * The default implementation does nothing, unless a [ContentScanner] is provided when building the client. */ fun interface ContentScannerService { - fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) + fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) } diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/DefaultContentScannerService.kt index 6906bf5c430..13c187a3f38 100644 --- 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 @@ -7,19 +7,17 @@ package io.element.android.features.contentscanner.impl -import androidx.collection.LruCache -import androidx.compose.runtime.MutableState import io.element.android.features.contentscanner.api.ContentScannerService -import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.scanner.ContentScanner -import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import java.io.IOException /** @@ -33,74 +31,31 @@ import java.io.IOException */ class DefaultContentScannerService( private val contentScanner: ContentScanner, - private val eventContentValidationCache: EventContentValidationCache, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, ) : ContentScannerService { - private val cache = LruCache(100) - - override fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) { - val url = mediaSource.safeUrl - val cachedResult = cache[eventId] - val needsScan = cachedResult?.needsScan(url) ?: true - if (!needsScan) { - updateState(cachedResult.state.value) - return - } - - val cachedValue = cache.getOrPut(eventId) { - ContentScannerResult( - eventId = eventId, - mutex = Mutex(), - state = eventContentValidationCache[eventId].state, - handledSources = mutableSetOf(), - ) - } - - sessionCoroutineScope.launch { - cachedValue.mutex.withLock { - if (cachedValue.state.value.dataOrNull() == false) { - // If the cached result is already a failure, we don't need to scan again - return@launch - } - updateState(AsyncData.Loading()) + override fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) { + for (mediaSource in mediaSources) { + sessionCoroutineScope.launch(coroutineDispatchers.io.limitedParallelism(4)) { + val url = mediaSource.safeUrl + val currentState = contentValidationState.getMediaState(url).first() + if (currentState != ContentValidationValue.Unknown) return@launch + contentValidationState.update(url, ContentValidationValue.Loading) contentScanner.scan(mediaSource) - .onSuccess { - val cachedValue = cache[eventId] - updateState(AsyncData.Success(it)) - - cachedValue?.addHandledSource(url) + .onSuccess { isValid -> + val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid + contentValidationState.update(url, contentValidationValue) } .onFailure { exception -> if (exception is IOException) { // If it's an IO-related exception, we can retry later, so we don't cache it - updateState(AsyncData.Uninitialized) + contentValidationState.update(url, ContentValidationValue.Unknown) } else { // For other exceptions, we cache the failure - updateState(AsyncData.Failure(exception)) - - cachedValue.addHandledSource(url) + contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception)) } } } } } } - -private data class ContentScannerResult( - val eventId: EventId, - val mutex: Mutex, - val state: MutableState>, - val handledSources: MutableSet, -) { - fun addHandledSource(url: String) { - handledSources.add(url) - } - - fun needsScan(url: String): Boolean { - return !handledSources.contains(url) - } -} - -private fun LruCache.getOrPut(key: K, defaultValue: (K) -> V): V { - return this[key] ?: defaultValue(key).also { put(key, it) } -} 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 index 8a4f48d1c86..ec28103ceb0 100644 --- 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 @@ -13,10 +13,10 @@ import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.contentscanner.impl.DefaultContentScannerService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.scanner.ContentScanner -import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import kotlinx.coroutines.CoroutineScope @BindingContainer @@ -26,14 +26,14 @@ object ContentScannerModule { @SingleIn(RoomScope::class) fun providesContentScannerService( contentScanner: ContentScanner?, - eventContentValidationCache: EventContentValidationCache, @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, + coroutineDispatchers: CoroutineDispatchers, ): ContentScannerService { return if (contentScanner != null) { DefaultContentScannerService( contentScanner = contentScanner, - eventContentValidationCache = eventContentValidationCache, - sessionCoroutineScope = sessionCoroutineScope + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = coroutineDispatchers, ) } else { NoopContentScannerService() diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt index c77fdf84b4c..4fa267ded03 100644 --- a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt +++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/NoopContentScannerService.kt @@ -8,9 +8,10 @@ package io.element.android.features.contentscanner.impl.di import io.element.android.features.contentscanner.api.ContentScannerService -import io.element.android.libraries.architecture.AsyncData 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. @@ -18,8 +19,11 @@ import io.element.android.libraries.matrix.api.media.MediaSource * This is used when the content scanner feature is not enabled or available. */ class NoopContentScannerService : ContentScannerService { - override fun scan(eventId: EventId, mediaSource: MediaSource, updateState: (AsyncData) -> Unit) { + override fun scan(eventId: EventId, mediaSources: List, contentValidationState: ContentValidationState) { // Always return success for the noop implementation - updateState(AsyncData.Success(true)) + for (mediaSource in mediaSources) { + val url = mediaSource.safeUrl + contentValidationState.update(url, ContentValidationValue.Valid) + } } } 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 c1d48ce303a..ec014a4ff61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -123,6 +123,7 @@ 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 @@ -174,7 +175,7 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId ?: return - if (eventContentValidationState.get(eventId).isInvalid()) return + if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index eaa00f427e3..80dd21b7b38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.location.api.LocationService import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.attachments.Attachment @@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.getDirectRoomMember import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.content +import io.element.android.libraries.matrix.ui.messages.reply.eventId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider @@ -135,6 +140,8 @@ class MessageComposerPresenter( private val notificationConversationService: NotificationConversationService, private val slashCommandService: SlashCommandService, private val featureFlagService: FeatureFlagService, + private val contentScannerService: ContentScannerService, + private val contentValidationCache: EventContentValidationCache, ) : Presenter { @AssistedFactory interface Factory { @@ -848,6 +855,11 @@ class MessageComposerPresenter( setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true) } else -> { + if (currentComposerMode is MessageComposerMode.Reply) { + val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch + val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()] + contentScannerService.scan(currentComposerMode.replyToDetails.eventId(), mediaSources, contentValidationState) + } // When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario. if (currentComposerMode.isEditing) { setText("", markdownTextEditorState, richTextEditorState) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index a884fc05c89..cfc3672ca38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -66,7 +66,7 @@ sealed interface TimelineEvent { data class ValidateMedia( val eventId: EventId, - val mediaSource: MediaSource, + 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 2a78c67f858..9a33299cd04 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -259,7 +259,7 @@ class TimelinePresenter( ) } is TimelineEvent.ValidateMedia -> { - timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSource, event.validationState)) + timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSources, event.validationState)) } } } 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 1bae7f3274e..1ebdc34c6c4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache @@ -83,10 +85,12 @@ fun MessageEventBubble( val cutTopStart = state.cutTopStart // Ignore state.isHighlighted for now, we need a design decision on it. - val backgroundBubbleColor = customBackgroundColor ?: 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 { @@ -104,13 +108,12 @@ fun MessageEventBubble( // Draw the background first, so that it's behind the content drawRect(backgroundBubbleColor) - if (borderColor != null) { - drawOutline(outline, borderColor, style = Stroke(width = 1.dp.toPx())) - } - // Then draw the content on top of it drawContent() + // Draw border color, if any + updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) } + // And then clip the top start corner if needed to make room for the avatar if (cutTopStart) { drawCircle( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index d2754790cb9..73667269bb2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -33,9 +33,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -121,7 +125,9 @@ 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.ContentValidationValue 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 @@ -469,16 +475,23 @@ private fun TimelineItemEventRowContent( ) } - val isContentInvalid = rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).isInvalid() + val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia) + .overallState + .collectAsState(initial = ContentValidationValue.Unknown) + val needsInvalidContentCustomisations = + // Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed + event.content !is TimelineItemGalleryContent && + event.content !is TimelineItemAttachmentsContent && + currentContentValidationState.isInvalid() // If the event has a dangerous media content without a preview, we need to set a custom message bubble background color val themeColors = ElementTheme.colors - val dangerousContentBubbleColor = remember(themeColors.isLight, isContentInvalid, event.content.type) { - themeColors.bgCriticalSubtle.takeIf { isContentInvalid && event.content.isMedia } + val dangerousContentBubbleColor = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) { + themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations && event.content.isMedia } } - val borderColor = remember(themeColors.isLight, isContentInvalid) { - themeColors.borderCriticalSubtle.takeIf { isContentInvalid } + val borderColor = remember(themeColors.isLight, needsInvalidContentCustomisations) { + themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations } } // Message bubble @@ -786,6 +799,9 @@ private fun MessageEventBubbleContent( val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content()) + val currentContentValidationState by contentValidationState + .overallState + .collectAsState(initial = ContentValidationValue.Unknown) val topPadding = if (showThreadDecoration) 0.dp else 8.dp val shape = RoundedCornerShape(6.dp) val inReplyToModifier = Modifier @@ -800,17 +816,24 @@ private fun MessageEventBubbleContent( inReplyToModifier.clickable(onClick = inReplyToClick) } - val borderColor = if (contentValidationState.isInvalid()) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary - val backgroundColor = if (contentValidationState.isInvalid()) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault + val isContentInvalid = currentContentValidationState.isInvalid() + val borderColor = if (isContentInvalid) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary + val backgroundColor = if (isContentInvalid) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault Box( modifier = talkbackCompatModifier .border(1.dp, 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, - contentValidationState = contentValidationState, + contentValidationValue = currentContentValidationState, hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), ) } @@ -830,10 +853,17 @@ private fun MessageEventBubbleContent( } } - val isContentDangerous = rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).isInvalid() + val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia) + .overallState + .collectAsState(initial = ContentValidationValue.Unknown) + val needsInvalidContentLayout = + // Gallery events should not apply custom paddings or layout dispositions + event.content !is TimelineItemGalleryContent && + event.content !is TimelineItemAttachmentsContent && + contentValidationState.isInvalid() - val timestampPosition = if (isContentDangerous) { - // THe invalid content view will be displayed in all these cases, independent of the event content + 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) { 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 58663415001..42c626f8457 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 @@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +33,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.Text 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.InvalidContentView /** @@ -48,29 +51,34 @@ fun TimelineItemAttachmentView( modifier: Modifier = Modifier, contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { + val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) Column( modifier = modifier, ) { - if (!contentValidationState.isInvalid()) { + if (currentContentValidation.isInvalid()) { + InvalidContentView( + // Add some spacing between the invalid content view and the caption + contentPadding = if (caption != null) PaddingValues(bottom = 8.dp) else PaddingValues.Zero, + onTextLayout = if (caption == null) { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + // Icon + horizontal paddings + extraWidth = 24.dp + 20.dp, + ) + } else { + {} + } + ) + } else { TimelineItemAttachmentHeaderView( icon = icon, iconContentDescription = iconContentDescription, filename = filename, fileExtensionAndSize = fileExtensionAndSize, hasCaption = caption != null, - isLoading = contentValidationState.state.value.isLoading(), + isLoading = currentContentValidation.isLoading(), onContentLayoutChange = onContentLayoutChange, ) - } else { - InvalidContentView( - // Add some spacing between the invalid content view and the caption - contentPadding = if (caption != null) PaddingValues(bottom = 8.dp) else PaddingValues.Zero, - onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChange = onContentLayoutChange, - // Icon + horizontal paddings - extraWidth = 24.dp + 20.dp, - ) - ) } if (caption != null) { TimelineItemAttachmentCaptionView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index e21da90e94d..4b2fcdc94a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -9,10 +9,12 @@ package io.element.android.features.messages.impl.timeline.components.event import android.text.SpannedString import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -21,11 +23,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,14 +58,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.flow.flowOf +import kotlin.math.max @Composable fun TimelineItemAttachmentsListView( + eventId: EventId?, content: TimelineItemAttachmentsContent, onGalleryItemClick: (Int) -> Unit, onLinkClick: (Link) -> Unit, @@ -65,21 +80,78 @@ fun TimelineItemAttachmentsListView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { + val contentValidationState = rememberEventContentValidationState( + eventId = eventId, + needsValidation = content.isMedia, + ) + Column(modifier = modifier) { Column( modifier = Modifier.fillMaxWidth(), ) { + val validationStates = remember(content.attachments) { + List(content.attachments.size) { ContentValidationValue.Unknown }.toMutableStateList() + } content.attachments.forEachIndexed { index, attachment -> - Column { - if (index > 0) { + Column(modifier = Modifier.fillMaxWidth()) { + val thumbnailContentValidationState by remember(attachment.thumbnailSource) { + attachment.thumbnailSource?.let { contentValidationState.getMediaState(it.safeUrl) } ?: flowOf(null) + }.collectAsState(initial = ContentValidationValue.Loading.takeIf { attachment.thumbnailSource != null }) + val mediaContentValidationState by contentValidationState.getMediaState( + attachment.mediaSource.safeUrl + ).collectAsState(initial = ContentValidationValue.Unknown) + + val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { + if (thumbnailContentValidationState?.isInvalid() == true || mediaContentValidationState.isInvalid()) { + ContentValidationValue.Invalid + } else if (thumbnailContentValidationState?.isLoading() == true || mediaContentValidationState.isLoading()) { + ContentValidationValue.Loading + } else { + mediaContentValidationState + } + } + + LaunchedEffect(itemContentValidationState) { + validationStates[index] = itemContentValidationState + } + + val needsSeparator = index in 1..max(1, content.attachments.lastIndex) + val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.isInvalid() == true + if (needsSeparator && !itemContentValidationState.isInvalid() && !isPreviousItemInvalid) { HorizontalDivider( color = ElementTheme.colors.borderInteractiveSecondary, ) } - AttachmentListItem( - attachment = attachment, - onClick = { onGalleryItemClick(index) }, - ) + + when (itemContentValidationState) { + ContentValidationValue.Invalid -> { + val shape = RoundedCornerShape(6.dp) + InvalidContentView( + modifier = Modifier + .border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape) + .clip(shape), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) + + if (index == content.attachments.lastIndex) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + } + ContentValidationValue.Loading -> { + CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally)) + } + ContentValidationValue.Valid -> { + AttachmentListItem( + attachment = attachment, + onClick = { onGalleryItemClick(index) }, + ) + } + else -> { + // TODO handle cases where either the content hasn't started validation or it failed to do so + } + } } } } @@ -213,6 +285,7 @@ internal fun TimelineItemAttachmentsListViewPreview( @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent ) = ElementPreview { TimelineItemAttachmentsListView( + eventId = EventId("\$eventId"), content = content, onGalleryItemClick = {}, onLinkClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index e0876aeaa22..3757919cc1f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -148,6 +148,7 @@ fun TimelineItemEventContentView( ) } is TimelineItemGalleryContent -> TimelineItemGalleryView( + eventId = eventId, content = content, onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLongClick = onLongClick, @@ -157,6 +158,7 @@ fun TimelineItemEventContentView( modifier = modifier, ) is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + eventId = eventId, content = content, onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLinkClick = onLinkClick, @@ -211,17 +213,19 @@ private fun ValidateMediaHelper( contentValidationState: ContentValidationState, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, ) { - val mediaSource = when (content) { - is TimelineItemImageContent -> content.mediaSource - is TimelineItemStickerContent -> content.mediaSource - is TimelineItemVideoContent -> content.mediaSource - is TimelineItemFileContent -> content.mediaSource - is TimelineItemAudioContent -> content.mediaSource - is TimelineItemVoiceContent -> content.mediaSource + 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, mediaSource) { - updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSource, contentValidationState)) + 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 879e911a3bf..c8f03658d12 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 @@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,10 +21,10 @@ 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.features.messages.impl.timeline.model.event.aTimelineItemFileContent -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings @@ -71,7 +70,7 @@ internal fun TimelineItemFileViewScanningContentPreview() { val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())) + eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)) ) ) } @@ -91,7 +90,7 @@ internal fun TimelineItemFileViewScanningContentFailedPreview() { val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(isValid = false) + eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)) ) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index c51055da929..625b10599c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,11 +54,15 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.bgSubtleTertiary import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.utils.time.formatShort import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.flowOf private const val MAX_TILES = 5 private val GALLERY_WIDTH = 264.dp @@ -67,6 +75,7 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp @Composable fun TimelineItemGalleryView( + eventId: EventId?, content: TimelineItemGalleryContent, onGalleryItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -87,21 +96,25 @@ fun TimelineItemGalleryView( when (totalItems) { 0 -> Unit 1 -> SingleItemLayout( + eventId = eventId, item = content.items[0], onClick = { onGalleryItemClick(0) }, onLongClick = onLongClick, ) 2 -> TwoItemLayout( + eventId = eventId, items = content.items, onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) 3 -> ThreeItemLayout( + eventId = eventId, items = content.items, onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) else -> FourPlusItemLayout( + eventId = eventId, items = content.items, showOverflow = showOverflow, overflowCount = overflowCount, @@ -139,11 +152,13 @@ fun TimelineItemGalleryView( @Composable private fun SingleItemLayout( + eventId: EventId?, item: GalleryItem, onClick: () -> Unit, onLongClick: (() -> Unit)?, ) { GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -157,6 +172,7 @@ private fun SingleItemLayout( @Composable private fun TwoItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -167,6 +183,7 @@ private fun TwoItemLayout( ) { items.forEachIndexed { index, item -> GalleryItemCell( + eventId = eventId, item = item, isLast = false, remainingCount = 0, @@ -182,6 +199,7 @@ private fun TwoItemLayout( @Composable private fun ThreeItemLayout( + eventId: EventId?, items: ImmutableList, onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, @@ -191,6 +209,7 @@ private fun ThreeItemLayout( verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { GalleryItemCell( + eventId = eventId, item = items[0], isLast = false, remainingCount = 0, @@ -206,6 +225,7 @@ private fun ThreeItemLayout( ) { for (it in 1..2) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -222,6 +242,7 @@ private fun ThreeItemLayout( @Composable private fun FourPlusItemLayout( + eventId: EventId?, items: ImmutableList, showOverflow: Boolean, overflowCount: Int, @@ -238,6 +259,7 @@ private fun FourPlusItemLayout( ) { for (it in 0..1) { GalleryItemCell( + eventId = eventId, item = items[it], isLast = false, remainingCount = 0, @@ -260,6 +282,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 +300,7 @@ private fun FourPlusItemLayout( @Composable private fun GalleryItemCell( + eventId: EventId?, item: GalleryItem, isLast: Boolean, remainingCount: Int, @@ -284,6 +308,24 @@ private fun GalleryItemCell( onLongClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { + val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true) + val thumbnailContentValidationState by remember(item.thumbnailSource) { + item.thumbnailSource?.let { eventContentValidationState.getMediaState(it.safeUrl) } ?: flowOf(null) + }.collectAsState(initial = ContentValidationValue.Loading.takeIf { item.thumbnailSource != null }) + val mediaContentValidationState by remember(item.mediaSource) { + eventContentValidationState.getMediaState(item.mediaSource.safeUrl) + }.collectAsState(initial = ContentValidationValue.Loading) + + val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { + if (thumbnailContentValidationState?.isInvalid() == true || mediaContentValidationState.isInvalid()) { + ContentValidationValue.Invalid + } else if (thumbnailContentValidationState?.isLoading() == true || mediaContentValidationState.isLoading()) { + ContentValidationValue.Loading + } else { + mediaContentValidationState + } + } + Box( modifier = modifier .blurHashBackground(item.blurhash, alpha = 0.9f) @@ -305,7 +347,20 @@ private fun GalleryItemCell( VideoOverlay(duration = item.duration) } - if (isLast && remainingCount > 0) { + if (itemContentValidationState.isLoading()) { + CircularProgressIndicator() + } else if (itemContentValidationState.isInvalid()) { + Box( + modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle) + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + imageVector = CompoundIcons.Error(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + } else if (isLast && remainingCount > 0) { RemainingCountOverlay(count = remainingCount) } } @@ -374,6 +429,7 @@ internal fun TimelineItemGalleryViewPreview( @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, ) = ElementPreview { TimelineItemGalleryView( + eventId = null, content = content, onGalleryItemClick = {}, onLongClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 729f550fd80..db815e20803 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 @@ -23,6 +23,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,7 +38,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow @@ -49,15 +49,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.exception.ClientException 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.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -90,8 +90,9 @@ fun TimelineItemImageView( Modifier } - val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading - val isDangerous = contentValidationState.isInvalid() + val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + val isContentBeingValidated = eventContentValidation.isLoading() + val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { TimelineItemAspectRatioBox( modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), @@ -125,15 +126,13 @@ fun TimelineItemImageView( contentScale = ContentScale.Crop, alignment = Alignment.Center, contentDescription = description, - onState = { - isLoaded = it is AsyncImagePainter.State.Success - val isDangerousFile = if (it is AsyncImagePainter.State.Error) { - val cause = it.result.throwable - cause is ClientException.ContentScanner && cause.reason.isDangerous() - } else { - false - } - contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailMediaRequestData.source, + state = state, + onLoaded = { isLoaded = true }, + contentValidationState = contentValidationState, + ) }, ) } @@ -210,7 +209,7 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -224,7 +223,7 @@ internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { internal fun TimelineItemImageViewScanningContentFailedPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 7b725c22c9b..8093fb8df69 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,7 +30,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter import io.element.android.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 @@ -39,16 +39,16 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -69,8 +69,9 @@ fun TimelineItemStickerView( ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) - val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading - val isDangerous = contentValidationState.isInvalid() + val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + val isContentBeingValidated = eventContentValidation.isLoading() + val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { Column( modifier = modifier.semantics { contentDescription = description }, @@ -114,15 +115,13 @@ fun TimelineItemStickerView( contentScale = ContentScale.Crop, alignment = Alignment.Center, contentDescription = description, - onState = { - isThumbnailLoaded = it is AsyncImagePainter.State.Success - val isDangerousFile = if (it is AsyncImagePainter.State.Error) { - val cause = it.result.throwable - cause is ClientException.ContentScanner && cause.reason.isDangerous() - } else { - false - } - contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailSource ?: content.mediaSource, + state = state, + onLoaded = { isThumbnailLoaded = true }, + contentValidationState = contentValidationState, + ) }, ) } @@ -162,7 +161,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -176,7 +175,7 @@ internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { internal fun TimelineItemStickerViewScanFailedContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 5ca0fb5f6af..ad6cef3dbe6 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 @@ -26,6 +26,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,7 +44,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.timeline.aTimelineItemEvent @@ -56,18 +56,18 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -102,8 +102,9 @@ fun TimelineItemVideoView( Modifier } - val isContentBeingValidated = contentValidationState.state.value is AsyncData.Loading - val isDangerous = contentValidationState.isInvalid() + val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + val isContentBeingValidated = eventContentValidation.isLoading() + val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { TimelineItemAspectRatioBox( modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), @@ -141,15 +142,13 @@ fun TimelineItemVideoView( contentScale = ContentScale.Crop, alignment = Alignment.Center, contentDescription = description, - onState = { - isLoaded = it is AsyncImagePainter.State.Success - val isDangerousFile = if (it is AsyncImagePainter.State.Error) { - val cause = it.result.throwable - cause is ClientException.ContentScanner && cause.reason.isDangerous() - } else { - false - } - contentValidationState.state.value = AsyncData.Success(!isDangerousFile) + onState = { state -> + handleAsyncImageStateChange( + mediaSource = content.thumbnailSource, + state = state, + onLoaded = { isLoaded = true }, + contentValidationState = contentValidationState, + ) }, ) @@ -280,7 +279,7 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mutableStateOf(AsyncData.Loading())))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -294,7 +293,7 @@ internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { internal fun TimelineItemVideoViewScanningContentFailedPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(isValid = false))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 9a76c0c4c25..7f53ce4a97d 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 @@ -22,6 +22,7 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,7 +51,6 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview @@ -61,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -80,7 +81,8 @@ fun TimelineItemVoiceView( modifier: Modifier = Modifier, contentValidationState: ContentValidationState = remember { ContentValidationState() }, ) { - if (contentValidationState.isInvalid()) { + val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + if (currentContentValidation is ContentValidationValue.Invalid) { InvalidContentView( modifier = modifier, onTextLayout = ContentAvoidingLayout.measureLastTextLine( @@ -128,7 +130,7 @@ fun TimelineItemVoiceView( verticalAlignment = Alignment.CenterVertically, ) { if (!isTalkbackActive()) { - if (contentValidationState.isValid()) { + if (currentContentValidation is ContentValidationValue.Valid) { when (state.buttonType) { VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) @@ -347,7 +349,7 @@ internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview { state = aVoiceMessageState(), content = aTimelineItemVoiceContent(), onContentLayoutChange = {}, - contentValidationState = remember { ContentValidationState(mutableStateOf(AsyncData.Loading())) } + contentValidationState = remember { ContentValidationState(mapOf("url" to ContentValidationValue.Loading)) } ) } @@ -358,7 +360,7 @@ internal fun TimelineItemVoiceViewScanningContentFailedPreview() = ElementPrevie val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(isValid = false) + eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)) ) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index c5ae6c05fe5..b3d90faba47 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -17,7 +17,7 @@ sealed interface TimelineItemEventContent { val type: String val isMedia: Boolean - get() = this is TimelineItemEventContentWithAttachment + get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent } interface TimelineItemEventMutableContent { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt index 45871cdfbc2..75a676cdb71 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt @@ -16,7 +16,7 @@ sealed interface TimelineProtectionEvent { data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent data class ValidateContent( val eventId: EventId, - val mediaSource: MediaSource, + val mediaSources: List, 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 a3a4302c509..dc70b63f328 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -25,7 +25,6 @@ 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 -import kotlinx.coroutines.launch @SingleIn(RoomScope::class) @Inject @@ -59,10 +58,8 @@ class TimelineProtectionPresenter( is TimelineProtectionEvent.ShowContent -> { allowedEvents += setOfNotNull(event.eventId) } - is TimelineProtectionEvent.ValidateContent -> room.roomCoroutineScope.launch { - contentScannerService.scan(event.eventId, event.mediaSource) { - event.validationState.state.value = it - } + is TimelineProtectionEvent.ValidateContent -> { + contentScannerService.scan(event.eventId, event.mediaSources, event.validationState) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt new file mode 100644 index 00000000000..6dd864815f4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/AsyncImageStateHandler.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.util + +import coil3.compose.AsyncImagePainter +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import okio.IOException + +fun handleAsyncImageStateChange( + mediaSource: MediaSource?, + state: AsyncImagePainter.State, + onLoaded: () -> Unit, + contentValidationState: ContentValidationState?, +) { + if (state is AsyncImagePainter.State.Success) { + onLoaded() + } + + val safeUrl = mediaSource?.safeUrl ?: return + val updatedContentValidationValue = when (state) { + is AsyncImagePainter.State.Empty -> ContentValidationValue.Unknown + is AsyncImagePainter.State.Loading -> ContentValidationValue.Loading + is AsyncImagePainter.State.Success -> ContentValidationValue.Valid + is AsyncImagePainter.State.Error -> { + when (val cause = state.result.throwable) { + is ClientException.ContentScanner if cause.reason.isDangerous() -> ContentValidationValue.Invalid + is IOException -> { + // Use `Unknown` instead of `Invalid` to allow retrying the download later + ContentValidationValue.Unknown + } + else -> ContentValidationValue.UnrecoverableError(cause) + } + } + } + + contentValidationState?.update(safeUrl, updatedContentValidationValue) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 4c5865525d4..59ad98fbeb4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -30,8 +30,8 @@ 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.featureflag.api.FeatureFlags 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 import io.element.android.libraries.matrix.api.core.RoomId 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 cf97c942c8b..0307c76b3f6 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 @@ -10,7 +10,6 @@ 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.architecture.AsyncData 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 @@ -23,6 +22,7 @@ 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.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test @@ -98,11 +98,13 @@ 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, _: MediaSource, callback: (AsyncData) -> Unit -> - callback(AsyncData.Success(true)) + val contentScannerService = lambdaRecorder { _: EventId, _: List, state: ContentValidationState -> + state.update(url, ContentValidationValue.Valid) } val presenter = createPresenter( mediaPreviewService = mediaPreviewService, @@ -112,12 +114,12 @@ class TimelineProtectionPresenterTest { presenter.test { val initialState = awaitItem() val validationState = ContentValidationState() - assertThat(validationState.isValid()).isFalse() + assertThat(validationState.getCurrentOverallState().isValid()).isFalse() initialState.eventSink( TimelineProtectionEvent.ValidateContent( eventId = AN_EVENT_ID, - mediaSource = MediaSource("https://example.com/media"), + mediaSources = listOf(MediaSource(url)), validationState = validationState ) ) @@ -125,14 +127,14 @@ class TimelineProtectionPresenterTest { runCurrent() contentScannerService.assertions().isCalledOnce() - assertThat(validationState.isValid()).isTrue() + assertThat(validationState.getCurrentOverallState().isValid()).isTrue() } } private fun createPresenter( room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), - contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: MediaSource, _: (AsyncData) -> Unit -> }, + contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: List, _: ContentValidationState -> }, ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, 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 f064c481da7..b1501601d9c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -127,8 +127,31 @@ data object UnknownContent : EventContent fun EventContent.isMediaContent(): Boolean { return when (this) { - is MessageContent -> type is MessageTypeWithAttachment + is MessageContent -> type is MessageTypeWithAttachment || type is GalleryMessageType is StickerContent -> true else -> false } } + +fun EventContent.mediaSources(): List { + return when (this) { + is MessageContent -> mediaSources() + is StickerContent -> listOfNotNull(source, info.thumbnailSource) + else -> emptyList() + } +} + +fun MessageContent.mediaSources(): List { + return when (val messageType = type) { + is MessageTypeWithAttachment -> when (messageType) { + is ImageMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + is VideoMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + is AudioMessageType -> listOf(messageType.source) + is VoiceMessageType -> listOf(messageType.source) + is FileMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource) + else -> emptyList() + } + is GalleryMessageType -> messageType.items.flatMap { it.mediaSources() } + else -> emptyList() + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index fdc538ec44a..2f61b8f58a1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -115,6 +115,14 @@ sealed interface GalleryItemType { data class Video(val content: VideoMessageType) : GalleryItemType data class File(val content: FileMessageType) : GalleryItemType data class Other(val itemType: String, val body: String) : GalleryItemType + + fun mediaSources(): List = when (this) { + is Image -> listOfNotNull(content.source, content.info?.thumbnailSource) + is Audio -> listOf(content.source) + is Video -> listOfNotNull(content.source, content.info?.thumbnailSource) + is File -> listOfNotNull(content.source, content.info?.thumbnailSource) + is Other -> emptyList() + } } data class OtherMessageType( diff --git a/libraries/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 index 2d388ced2ff..3eff31392b8 100644 --- 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 @@ -7,46 +7,85 @@ package io.element.android.libraries.matrix.ui.media.contentvalidation -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable -import androidx.compose.runtime.mutableStateOf -import io.element.android.libraries.architecture.AsyncData +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest /** - * Wrapper for the content validation state of an event. It holds a [MutableState] of [AsyncData] that represents the validation state. + * Wrapper for the content validation state of an event. */ -@Stable +@Immutable class ContentValidationState( - val state: MutableState>, + private val states: MutableStateFlow>, ) { - /** - * Creates a new [ContentValidationState] with an uninitialized state. - */ - constructor() : this(state = mutableStateOf(AsyncData.Uninitialized)) + @OptIn(ExperimentalCoroutinesApi::class) + val overallState: Flow = states + .mapLatest { states -> calculateOverallState(states) } + .distinctUntilChanged() /** - * Creates a new [ContentValidationState] with a given validation state. + * Creates a new [ContentValidationState] with a [ContentValidationValue.Valid] state. */ - constructor(isValid: Boolean) : this(state = mutableStateOf(AsyncData.Success(isValid))) + constructor() : this(states = MutableStateFlow(emptyMap())) - /** - * Returns true if the content is valid, false otherwise. If the state is uninitialized or loading, it returns false. - */ - fun isValid(): Boolean { - return state.value.dataOrNull() == true + constructor(initial: Map) : this(states = MutableStateFlow(initial)) + + @OptIn(ExperimentalCoroutinesApi::class) + fun getMediaState(url: String): Flow { + return states.mapLatest { states -> + states[url] ?: ContentValidationValue.Unknown + }.distinctUntilChanged() } - /** - * Returns true if the content is invalid, false otherwise. If the state is uninitialized or loading, it returns false. - */ - fun isInvalid(): Boolean { - return state.value.dataOrNull() == false + fun getCurrentOverallState(): ContentValidationValue { + return calculateOverallState(states.value) } /** * Updates the validation state with a new value. */ - fun update(newState: AsyncData) { - state.value = newState + fun update(url: String, newValue: ContentValidationValue) { + val current = states.value[url] + if (current != null && current.isValidated() && newValue.isLoading()) { + return + } + + val updated = states.value.toMutableMap() + updated[url] = newValue + states.value = updated } + + private fun calculateOverallState(states: Map): ContentValidationValue { + return when { + states.isEmpty() -> ContentValidationValue.Valid + states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid + states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading + states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid + else -> ContentValidationValue.Unknown + } + } +} + +@Immutable +sealed interface ContentValidationValue { + data object Valid : ContentValidationValue + data object Invalid : ContentValidationValue + data object Loading : ContentValidationValue + data class UnrecoverableError(val error: Throwable) : ContentValidationValue + data object Unknown : ContentValidationValue + + fun isValidated(): Boolean = when (this) { + is Valid -> true + is Invalid -> true + is Loading -> false + is UnrecoverableError -> true + is Unknown -> false + } + + fun isValid(): Boolean = this is Valid + fun isInvalid(): Boolean = this is Invalid + fun isLoading(): Boolean = this is Loading } 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 index 26bea65c0f8..263e08c1524 100644 --- 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 @@ -9,21 +9,26 @@ package io.element.android.libraries.matrix.ui.media.contentvalidation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -38,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun InvalidContentView( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues.Zero, - onTextLayout: (TextLayoutResult) -> Unit = {}, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { val updatedOnTextLayout by rememberUpdatedState(onTextLayout) Row( @@ -61,14 +66,32 @@ fun InvalidContentView( ) val text = stringResource(CommonStrings.content_scanner_unsafe_message) val textMeasurer = rememberTextMeasurer() - LaunchedEffect(text) { - updatedOnTextLayout(textMeasurer.measure(text)) + val textContent = remember(text) { + movableContentOf( + @Composable { + Text( + text = text, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + ) + } + + // BoxWithConstraints is needed to be able to calculate the text layout in the current constraints, so we can pass it to the onTextLayout callback. + // However, this can't be used inside a SubComposeLayout (like the one in the text composer), so we only use it when the onTextLayout callback + // is provided. + if (updatedOnTextLayout != null) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + LaunchedEffect(text) { + updatedOnTextLayout?.invoke(textMeasurer.measure(text, overflow = TextOverflow.Visible, constraints = constraints)) + } + + textContent() + } + } else { + textContent() } - Text( - text = text, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodyMdRegular, - ) } } } 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 index f3c21637974..3149166e157 100644 --- 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 @@ -8,16 +8,16 @@ package io.element.android.libraries.matrix.ui.media.contentvalidation import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf -import io.element.android.libraries.architecture.AsyncData 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 = ContentValidationState() + /** * A composition local that provides an [EventContentValidationCache] instance, so we can check if a given event content has been validated or not. */ @@ -27,9 +27,7 @@ val LocalEventContentValidationState = staticCompositionLocalOf when { - contentValidationState.isValid() -> ReplyToReadyContent( + is InReplyToDetails.Ready -> when (contentValidationValue) { + ContentValidationValue.Valid -> ReplyToReadyContent( senderId = inReplyTo.senderId, senderProfile = inReplyTo.senderProfile, metadata = inReplyTo.metadata(hideImage), maxLines = maxLines, modifier = modifier, ) - contentValidationState.isInvalid() -> ReplyToInvalidContent() + ContentValidationValue.Invalid -> ReplyToInvalidContent() else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> @@ -225,7 +225,8 @@ private fun ReplyToInvalidContent( ) { InvalidContentView( modifier = modifier, - contentPadding = PaddingValues(horizontal = 8.dp) + contentPadding = PaddingValues(horizontal = 8.dp), + onTextLayout = null, ) } @@ -235,7 +236,7 @@ internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsP InReplyToView( inReplyTo = inReplyTo, hideImage = false, - contentValidationState = remember { ContentValidationState(isValid = true) }, + contentValidationValue = ContentValidationValue.Valid, ) } 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 6c969450e75..cd5019bd7da 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 @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,7 +38,7 @@ 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.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue 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 @@ -139,22 +141,21 @@ private fun ReplyToModeView( modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(6.dp) - val contentValidationState = if (replyToDetails is InReplyToDetails.Ready) { - rememberEventContentValidationState(replyToDetails.eventId(), replyToDetails.eventContent) - } else { - remember { ContentValidationState() } - } + val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent) + val currentValidationState by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Unknown) + + val isContentInvalid = currentValidationState.isInvalid() Box( modifier .clip(shape) .background( - color = if (contentValidationState.isInvalid()) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault, + color = if (isContentInvalid) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault, shape = shape, ) .border( width = 1.dp, - color = if (contentValidationState.isInvalid()) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary, + color = if (isContentInvalid) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary, shape = shape, ) .padding(4.dp) @@ -167,14 +168,14 @@ private fun ReplyToModeView( InReplyToView( inReplyTo = replyToDetails, hideImage = hideImage, - contentValidationState = contentValidationState, + contentValidationValue = currentValidationState, maxLines = maxReplyContentLines, modifier = Modifier.fillMaxWidth(), ) Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close), - tint = if (contentValidationState.isInvalid()) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary, + tint = if (isContentInvalid) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary, modifier = Modifier .align(Alignment.TopEnd) .padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp) From 7977b285bcab36286d1a7730efb35350a0da7f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 8 Jul 2026 16:55:10 +0200 Subject: [PATCH 25/27] Fix tests --- .../DefaultEventContentValidationCache.kt | 3 +- .../event/TimelineItemAttachmentView.kt | 3 +- .../components/event/TimelineItemAudioView.kt | 3 +- .../components/event/TimelineItemFileView.kt | 7 +-- .../components/event/TimelineItemImageView.kt | 7 +-- .../event/TimelineItemStickerView.kt | 7 +-- .../components/event/TimelineItemVideoView.kt | 7 +-- .../components/event/TimelineItemVoiceView.kt | 7 +-- ...essageComposerPresenterSlashCommandTest.kt | 3 ++ .../MessageComposerPresenterTest.kt | 3 ++ .../TimelineProtectionPresenterTest.kt | 5 +- .../ContentValidationState.kt | 46 ++++++++++++------- .../InMemoryEventContentValidationCache.kt | 2 +- .../LocalEventContentValidationState.kt | 2 +- .../impl/datasource/EventItemFactoryTest.kt | 2 + .../GalleryMediaGalleryDataSourceTest.kt | 10 ++++ .../impl/viewer/MediaViewerPresenterTest.kt | 1 + 17 files changed, 79 insertions(+), 39 deletions(-) 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 index efaa97fa9cc..73cd8c08574 100644 --- 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 @@ -12,6 +12,7 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import java.util.concurrent.ConcurrentHashMap @@ -21,6 +22,6 @@ class DefaultEventContentValidationCache : EventContentValidationCache { private val cache = ConcurrentHashMap() override operator fun get(eventId: EventId): ContentValidationState { - return cache.getOrPut(eventId) { ContentValidationState() } + return cache.getOrPut(eventId) { DefaultContentValidationState() } } } 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 42c626f8457..e32a121b141 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 @@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView /** @@ -49,7 +50,7 @@ fun TimelineItemAttachmentView( caption: String?, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) Column( 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 42366283977..2f97c30e2de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -18,13 +18,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Audio(), 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 c8f03658d12..5bae7fbe8c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState import io.element.android.libraries.ui.strings.CommonStrings @@ -37,7 +38,7 @@ fun TimelineItemFileView( content: TimelineItemFileContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { TimelineItemAttachmentView( icon = CompoundIcons.Attachment(), @@ -70,7 +71,7 @@ internal fun TimelineItemFileViewScanningContentPreview() { val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)) + eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)) ) ) } @@ -90,7 +91,7 @@ internal fun TimelineItemFileViewScanningContentFailedPreview() { val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)) + eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)) ) ) } 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 db815e20803..4d5475f18e1 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 @@ -58,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -79,7 +80,7 @@ fun TimelineItemImageView( onShowContentClick: () -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val a11yLabel = stringResource(CommonStrings.common_image) val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel @@ -209,7 +210,7 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -223,7 +224,7 @@ internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview { internal fun TimelineItemImageViewScanningContentFailedPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 8093fb8df69..6534e3332fc 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 @@ -49,6 +49,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -65,7 +66,7 @@ fun TimelineItemStickerView( onShowClick: () -> Unit, modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) @@ -161,7 +162,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -175,7 +176,7 @@ internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview { internal fun TimelineItemStickerViewScanFailedContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(initial = mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 ad6cef3dbe6..96d8a2b390e 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 @@ -68,6 +68,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -88,7 +89,7 @@ fun TimelineItemVideoView( onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val isTalkbackActive = isTalkbackActive() val a11yLabel = stringResource(CommonStrings.common_video) @@ -279,7 +280,7 @@ internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( @@ -293,7 +294,7 @@ internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview { internal fun TimelineItemVideoViewScanningContentFailedPreview() = ElementPreview { val eventId = EventId("\$eventId") val cache = remember { - InMemoryEventContentValidationCache(initial = mapOf(eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) + InMemoryEventContentValidationCache(initial = mapOf(eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)))) } CompositionLocalProvider(LocalEventContentValidationState provides cache) { ATimelineItemEventRow( 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 7f53ce4a97d..3f41cdeb5eb 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 @@ -62,6 +62,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -79,7 +80,7 @@ fun TimelineItemVoiceView( content: TimelineItemVoiceContent, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - contentValidationState: ContentValidationState = remember { ContentValidationState() }, + contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) if (currentContentValidation is ContentValidationValue.Invalid) { @@ -349,7 +350,7 @@ internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview { state = aVoiceMessageState(), content = aTimelineItemVoiceContent(), onContentLayoutChange = {}, - contentValidationState = remember { ContentValidationState(mapOf("url" to ContentValidationValue.Loading)) } + contentValidationState = remember { DefaultContentValidationState(mapOf("url" to ContentValidationValue.Loading)) } ) } @@ -360,7 +361,7 @@ internal fun TimelineItemVoiceViewScanningContentFailedPreview() = ElementPrevie val cache = remember { InMemoryEventContentValidationCache( initial = mapOf( - eventId to ContentValidationState(mapOf("url" to ContentValidationValue.Invalid)) + eventId to DefaultContentValidationState(mapOf("url" to ContentValidationValue.Invalid)) ) ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index 61b1fc8d261..0ac8c3be1f0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig @@ -315,6 +316,8 @@ class MessageComposerPresenterSlashCommandTest { notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, featureFlagService = featureFlagService, + contentScannerService = { _, _, _ -> }, + contentValidationCache = InMemoryEventContentValidationCache(), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 499e4bf91a3..c164a301601 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider @@ -1649,6 +1650,8 @@ class MessageComposerPresenterTest : RobolectricTest() { notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, featureFlagService = featureFlagService, + contentScannerService = { _, _, _ -> }, + contentValidationCache = InMemoryEventContentValidationCache(), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index 0307c76b3f6..11093bd74f9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -23,6 +23,7 @@ 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 @@ -112,10 +113,10 @@ class TimelineProtectionPresenterTest { contentScannerService = contentScannerService, ) presenter.test { - val initialState = awaitItem() - val validationState = ContentValidationState() + 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, 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 index 3eff31392b8..508842cab19 100644 --- 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 @@ -18,36 +18,49 @@ import kotlinx.coroutines.flow.mapLatest * Wrapper for the content validation state of an event. */ @Immutable -class ContentValidationState( +interface ContentValidationState { + val overallState: Flow + fun getMediaState(url: String): Flow + fun getCurrentOverallState(): ContentValidationValue + fun update(url: String, newValue: ContentValidationValue) +} + +@Immutable +class NoopContentValidationState : ContentValidationState { + override val overallState: Flow = MutableStateFlow(ContentValidationValue.Valid) + override fun getMediaState(url: String): Flow = MutableStateFlow(ContentValidationValue.Valid) + override fun getCurrentOverallState(): ContentValidationValue = ContentValidationValue.Valid + override fun update(url: String, newValue: ContentValidationValue) {} +} + +@Immutable +class DefaultContentValidationState( private val states: MutableStateFlow>, -) { +) : ContentValidationState { @OptIn(ExperimentalCoroutinesApi::class) - val overallState: Flow = states + override val overallState: Flow = states .mapLatest { states -> calculateOverallState(states) } .distinctUntilChanged() - /** - * Creates a new [ContentValidationState] with a [ContentValidationValue.Valid] state. - */ constructor() : this(states = MutableStateFlow(emptyMap())) constructor(initial: Map) : this(states = MutableStateFlow(initial)) @OptIn(ExperimentalCoroutinesApi::class) - fun getMediaState(url: String): Flow { + override fun getMediaState(url: String): Flow { return states.mapLatest { states -> states[url] ?: ContentValidationValue.Unknown }.distinctUntilChanged() } - fun getCurrentOverallState(): ContentValidationValue { + override fun getCurrentOverallState(): ContentValidationValue { return calculateOverallState(states.value) } /** * Updates the validation state with a new value. */ - fun update(url: String, newValue: ContentValidationValue) { + override fun update(url: String, newValue: ContentValidationValue) { val current = states.value[url] if (current != null && current.isValidated() && newValue.isLoading()) { return @@ -57,15 +70,14 @@ class ContentValidationState( updated[url] = newValue states.value = updated } +} - private fun calculateOverallState(states: Map): ContentValidationValue { - return when { - states.isEmpty() -> ContentValidationValue.Valid - states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid - states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading - states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid - else -> ContentValidationValue.Unknown - } +private fun calculateOverallState(states: Map): ContentValidationValue { + return when { + states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid + states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading + states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid + else -> ContentValidationValue.Unknown } } 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 index 9171cf85163..12bde5abb79 100644 --- 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 @@ -15,7 +15,7 @@ class InMemoryEventContentValidationCache( private val cache = initial.toMutableMap() override operator fun get(eventId: EventId): ContentValidationState { - return cache[eventId] ?: ContentValidationState() + return cache[eventId] ?: DefaultContentValidationState() } fun put(eventId: EventId, state: ContentValidationState) { 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 index 3149166e157..ab7f6da0426 100644 --- 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 @@ -16,7 +16,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten 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 = ContentValidationState() +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. 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 518e808579b..7147e74e723 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt @@ -439,6 +439,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -500,6 +501,7 @@ class EventItemFactoryTest { ), mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt index 710aaff1abf..670dfd62428 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt @@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), type = GalleryItemData.Type.Image, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest { mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg), mediaSource = MediaSource("image_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), type = GalleryItemData.Type.Video, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest { mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4), mediaSource = MediaSource("video_url"), thumbnailSource = MediaSource("thumbnail_url"), + blurHash = null, ) ) } @@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("audio_url"), thumbnailSource = null, type = GalleryItemData.Type.Audio, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("file_url"), thumbnailSource = null, type = GalleryItemData.Type.File, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = null, type = GalleryItemData.Type.Image, + blurHash = null, ) ), galleryInfo = aGalleryInfo(), @@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("image_url"), thumbnailSource = null, type = GalleryItemData.Type.Image, + blurHash = null, ), GalleryItemData( filename = "document.pdf", @@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("file_url"), thumbnailSource = null, type = GalleryItemData.Type.File, + blurHash = null, ), GalleryItemData( filename = "video.mp4", @@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest { mediaSource = MediaSource("video_url"), thumbnailSource = null, type = GalleryItemData.Type.Video, + blurHash = null, ), ), galleryInfo = aGalleryInfo(), diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index bdf4e352d68..b25b8577ee9 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -114,6 +114,7 @@ class MediaViewerPresenterTest { avatarInfo = AvatarInfo(filename = "avatar.png"), mediaSource = aMediaSource(), thumbnailSource = null, + blurHash = null, ), room = FakeJoinedRoom( baseRoom = FakeBaseRoom( From 143d596a65467cca2f2519fe7d121dd48d455ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 8 Jul 2026 18:10:10 +0200 Subject: [PATCH 26/27] Try fixing screenshot tests --- .../impl/DefaultContentScannerService.kt | 2 +- .../components/TimelineItemEventRow.kt | 15 +++---- .../event/TimelineItemAttachmentView.kt | 6 +-- .../event/TimelineItemAttachmentsListView.kt | 13 +++--- .../event/TimelineItemGalleryView.kt | 13 +++--- .../components/event/TimelineItemImageView.kt | 5 ++- .../event/TimelineItemStickerView.kt | 5 ++- .../components/event/TimelineItemVideoView.kt | 5 ++- .../components/event/TimelineItemVoiceView.kt | 3 +- .../ContentValidationState.kt | 41 ++++++++++++++++--- .../textcomposer/ComposerModeView.kt | 4 +- .../src/test/kotlin/base/ScreenshotTest.kt | 3 ++ 12 files changed, 70 insertions(+), 45 deletions(-) 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 index 13c187a3f38..5e029d7a768 100644 --- 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 @@ -38,7 +38,7 @@ class DefaultContentScannerService( for (mediaSource in mediaSources) { sessionCoroutineScope.launch(coroutineDispatchers.io.limitedParallelism(4)) { val url = mediaSource.safeUrl - val currentState = contentValidationState.getMediaState(url).first() + val currentState = contentValidationState.getMediaStateFlow(url).first() if (currentState != ContentValidationValue.Unknown) return@launch contentValidationState.update(url, ContentValidationValue.Loading) contentScanner.scan(mediaSource) 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 73667269bb2..26e041e367e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -127,7 +127,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua 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.ContentValidationValue +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 @@ -476,8 +476,7 @@ private fun TimelineItemEventRowContent( } val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia) - .overallState - .collectAsState(initial = ContentValidationValue.Unknown) + .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 && @@ -798,10 +797,8 @@ private fun MessageEventBubbleContent( } val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> - val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content()) - val currentContentValidationState by contentValidationState - .overallState - .collectAsState(initial = ContentValidationValue.Unknown) + 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 @@ -853,9 +850,7 @@ private fun MessageEventBubbleContent( } } - val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia) - .overallState - .collectAsState(initial = ContentValidationValue.Unknown) + 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 && 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 e32a121b141..8ed9358d0cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -33,9 +33,9 @@ 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.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState -import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. @@ -52,7 +52,7 @@ fun TimelineItemAttachmentView( modifier: Modifier = Modifier, contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { - val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + val currentContentValidation by contentValidationState.collectOverallState() Column( modifier = modifier, ) { @@ -77,7 +77,7 @@ fun TimelineItemAttachmentView( filename = filename, fileExtensionAndSize = fileExtensionAndSize, hasCaption = caption != null, - isLoading = currentContentValidation.isLoading(), + isLoading = !currentContentValidation.isValidated(), onContentLayoutChange = onContentLayoutChange, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 4b2fcdc94a4..bd6ab64fe7e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -62,6 +62,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings @@ -94,17 +95,13 @@ fun TimelineItemAttachmentsListView( } content.attachments.forEachIndexed { index, attachment -> Column(modifier = Modifier.fillMaxWidth()) { - val thumbnailContentValidationState by remember(attachment.thumbnailSource) { - attachment.thumbnailSource?.let { contentValidationState.getMediaState(it.safeUrl) } ?: flowOf(null) - }.collectAsState(initial = ContentValidationValue.Loading.takeIf { attachment.thumbnailSource != null }) - val mediaContentValidationState by contentValidationState.getMediaState( - attachment.mediaSource.safeUrl - ).collectAsState(initial = ContentValidationValue.Unknown) + val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl) + val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl) val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { - if (thumbnailContentValidationState?.isInvalid() == true || mediaContentValidationState.isInvalid()) { + if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) { ContentValidationValue.Invalid - } else if (thumbnailContentValidationState?.isLoading() == true || mediaContentValidationState.isLoading()) { + } else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) { ContentValidationValue.Loading } else { mediaContentValidationState 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 625b10599c3..9aaaa01e31b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.theme.bgSubtleTertiary import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.utils.time.formatShort @@ -309,17 +310,13 @@ private fun GalleryItemCell( modifier: Modifier = Modifier, ) { val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true) - val thumbnailContentValidationState by remember(item.thumbnailSource) { - item.thumbnailSource?.let { eventContentValidationState.getMediaState(it.safeUrl) } ?: flowOf(null) - }.collectAsState(initial = ContentValidationValue.Loading.takeIf { item.thumbnailSource != null }) - val mediaContentValidationState by remember(item.mediaSource) { - eventContentValidationState.getMediaState(item.mediaSource.safeUrl) - }.collectAsState(initial = ContentValidationValue.Loading) + val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl) + val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl) val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) { - if (thumbnailContentValidationState?.isInvalid() == true || mediaContentValidationState.isInvalid()) { + if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) { ContentValidationValue.Invalid - } else if (thumbnailContentValidationState?.isLoading() == true || mediaContentValidationState.isLoading()) { + } else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) { ContentValidationValue.Loading } else { mediaContentValidationState 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 4d5475f18e1..6773351b465 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -62,6 +62,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultCon import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -91,8 +92,8 @@ fun TimelineItemImageView( Modifier } - val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) - val isContentBeingValidated = eventContentValidation.isLoading() + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { TimelineItemAspectRatioBox( 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 6534e3332fc..583840dcdd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultCon import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 @@ -70,8 +71,8 @@ fun TimelineItemStickerView( ) { val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) - val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) - val isContentBeingValidated = eventContentValidation.isLoading() + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { Column( 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 96d8a2b390e..d6e489dd010 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultCon import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive @@ -103,8 +104,8 @@ fun TimelineItemVideoView( Modifier } - val eventContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) - val isContentBeingValidated = eventContentValidation.isLoading() + val eventContentValidation by contentValidationState.collectOverallState() + val isContentBeingValidated = !eventContentValidation.isValidated() val isDangerous = eventContentValidation.isInvalid() if (isContentBeingValidated || !isDangerous) { TimelineItemAspectRatioBox( 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 3f41cdeb5eb..9f5df7dca48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultCon import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent @@ -82,7 +83,7 @@ fun TimelineItemVoiceView( modifier: Modifier = Modifier, contentValidationState: ContentValidationState = remember { DefaultContentValidationState() }, ) { - val currentContentValidation by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Loading) + val currentContentValidation by contentValidationState.collectOverallState() if (currentContentValidation is ContentValidationValue.Invalid) { InvalidContentView( modifier = modifier, 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 index 508842cab19..94d2512f510 100644 --- 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 @@ -7,7 +7,12 @@ 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 @@ -19,17 +24,19 @@ import kotlinx.coroutines.flow.mapLatest */ @Immutable interface ContentValidationState { - val overallState: Flow - fun getMediaState(url: String): Flow + val overallStateFlow: Flow + fun getMediaStateFlow(url: String): Flow fun getCurrentOverallState(): ContentValidationValue + fun getCurrentMediaState(url: String): ContentValidationValue fun update(url: String, newValue: ContentValidationValue) } @Immutable class NoopContentValidationState : ContentValidationState { - override val overallState: Flow = MutableStateFlow(ContentValidationValue.Valid) - override fun getMediaState(url: String): Flow = MutableStateFlow(ContentValidationValue.Valid) + override val overallStateFlow: Flow = MutableStateFlow(ContentValidationValue.Valid) + override fun getMediaStateFlow(url: String): Flow = MutableStateFlow(ContentValidationValue.Valid) override fun getCurrentOverallState(): ContentValidationValue = ContentValidationValue.Valid + override fun getCurrentMediaState(url: String): ContentValidationValue = ContentValidationValue.Valid override fun update(url: String, newValue: ContentValidationValue) {} } @@ -38,7 +45,7 @@ class DefaultContentValidationState( private val states: MutableStateFlow>, ) : ContentValidationState { @OptIn(ExperimentalCoroutinesApi::class) - override val overallState: Flow = states + override val overallStateFlow: Flow = states .mapLatest { states -> calculateOverallState(states) } .distinctUntilChanged() @@ -47,7 +54,7 @@ class DefaultContentValidationState( constructor(initial: Map) : this(states = MutableStateFlow(initial)) @OptIn(ExperimentalCoroutinesApi::class) - override fun getMediaState(url: String): Flow { + override fun getMediaStateFlow(url: String): Flow { return states.mapLatest { states -> states[url] ?: ContentValidationValue.Unknown }.distinctUntilChanged() @@ -57,6 +64,10 @@ class DefaultContentValidationState( return calculateOverallState(states.value) } + override fun getCurrentMediaState(url: String): ContentValidationValue { + return states.value[url] ?: ContentValidationValue.Unknown + } + /** * Updates the validation state with a new value. */ @@ -101,3 +112,21 @@ sealed interface ContentValidationValue { fun isInvalid(): Boolean = this is Invalid fun isLoading(): Boolean = this is Loading } + +@Composable +fun ContentValidationState.collectOverallState(): State { + return produceState(getCurrentOverallState()) { + overallStateFlow.collect { value = it } + } +} + +@Composable +fun ContentValidationState.collectMediaState(url: String?): State { + if (url == null) { + return remember { mutableStateOf(ContentValidationValue.Unknown) } + } + + return produceState(getCurrentMediaState(url)) { + getMediaStateFlow(url).collect { value = it } + } +} diff --git a/libraries/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 cd5019bd7da..a55e725c047 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -38,7 +38,7 @@ 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.ContentValidationValue +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 @@ -142,7 +142,7 @@ private fun ReplyToModeView( ) { val shape = RoundedCornerShape(6.dp) val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent) - val currentValidationState by contentValidationState.overallState.collectAsState(initial = ContentValidationValue.Unknown) + val currentValidationState by contentValidationState.collectOverallState() val isContentInvalid = currentValidationState.isInvalid() diff --git a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt index e9bd34d10ef..971783cd630 100644 --- a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt +++ b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt @@ -26,6 +26,8 @@ import com.android.resources.Density.DEFAULT_DENSITY import com.android.resources.NightMode import com.android.resources.ScreenOrientation import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState +import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview import java.util.Locale @@ -55,6 +57,7 @@ object ScreenshotTest { setLocales(LocaleList(locale)) uiMode = preview.previewInfo.uiMode }, + LocalEventContentValidationState provides NoopEventContentValidationCache(), ) { ElementTheme { Box( From dd3954942fb18e83e7a3e14bb77362816ef8e21b Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 8 Jul 2026 17:34:59 +0000 Subject: [PATCH 27/27] Update screenshots --- ...vent_TimelineItemStickerViewScanFailedContent_Day_0_en.png | 4 ++-- ...nt_TimelineItemStickerViewScanFailedContent_Night_0_en.png | 4 ++-- ...ts.event_TimelineItemVideoViewScanningContent_Day_0_en.png | 4 ++-- ....event_TimelineItemVideoViewScanningContent_Night_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_1_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_2_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_3_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_0_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_1_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_2_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_3_en.png | 4 ++-- ...components.event_TimelineItemVoiceViewUnified_Day_0_en.png | 4 ++-- ...mponents.event_TimelineItemVoiceViewUnified_Night_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_0_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_10_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_11_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_12_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_13_en.png | 4 ++-- ...eline.components.event_TimelineItemVoiceView_Day_14_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_1_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_2_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_3_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_4_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_5_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_6_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_7_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_8_en.png | 4 ++-- ...meline.components.event_TimelineItemVoiceView_Day_9_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_0_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_10_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_11_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_12_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_13_en.png | 4 ++-- ...ine.components.event_TimelineItemVoiceView_Night_14_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_1_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_2_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_3_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_4_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_5_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_6_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_7_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_8_en.png | 4 ++-- ...line.components.event_TimelineItemVoiceView_Night_9_en.png | 4 ++-- ...omponents.event_TimelineVideoWithCaptionRow_Night_0_en.png | 4 ++-- 45 files changed, 90 insertions(+), 90 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png index 80fc2195e8d..d28cd04dc0d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:441535bf89e8e9362f9e39b024a0ff117c952310f621da2dce96682dc45a8cf8 -size 21234 +oid sha256:5961fcb0f13d3e17be829d8beb4de22cebf90cc065dde6fa6e2f817ea1ca3827 +size 21568 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png index 1fba202cab2..998a8437089 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemStickerViewScanFailedContent_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:195adb4e0b8049523485709ab801adbab64cc4eab1c9f3b96e0106f857cb985c -size 21486 +oid sha256:f7c8cf238a5953cfb723c3f83d1c3bd8efd74a5472494abbae9e5afb2375abc7 +size 21672 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png index 5176d81d926..b683d835823 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0266ca778ef09fdf62d306e485b90cf13aea8b0cab4ce0ae3dd2c096c9992171 -size 282455 +oid sha256:710c36047d9e1ca7c3d242cd53ac9232c536c36b3706ae22f49ab62e05e57a23 +size 284681 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png index 86e2d847de4..34d69603dc7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoViewScanningContent_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2338265e5c5f7efb662bbe262e95b12fd719022a92652a2a3b4cf0336026c576 -size 281597 +oid sha256:380a80cb0cfdd1c0ed2bb90793e640552323c9719b1914e10ca6be0abc8c9929 +size 283849 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png index 9d1e883dc15..9238837c39e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49f496a8609b89c8ff1837df4b2979d781907a1480918178f43c07f048a40583 -size 277030 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png index fb1be62b45d..861f05e267e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:404fb5170e377c87d7add204458137e93f21a9504a086aeb47e42530dc362948 -size 508620 +oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 +size 506341 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png index d27fa88138b..c48e8c45b6b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d22564f849d029c6371e8e0ef7043162431dcfba21ac66ce1f565694b6afa1e -size 386342 +oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 +size 383712 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png index 9d1e883dc15..9238837c39e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49f496a8609b89c8ff1837df4b2979d781907a1480918178f43c07f048a40583 -size 277030 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png index 5535e3b7f2f..9238837c39e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f7c0aa212f2bc3e45d9fb9001d1b640ab0d115379826ac1479c4d560761d55a -size 277097 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png index c53486b179f..861f05e267e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:140e83bec7563ae749407d956f3d373c4ea2a74b563c067ba9eec9c58345e326 -size 508670 +oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 +size 506341 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png index 1494f16accd..c48e8c45b6b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc84f60f0f5e697abe249f60c200eaa9544d84e91cc9e5d32a3bdbb82d5a73ce -size 386387 +oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 +size 383712 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png index 5535e3b7f2f..9238837c39e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f7c0aa212f2bc3e45d9fb9001d1b640ab0d115379826ac1479c4d560761d55a -size 277097 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png index d6a814bf230..aa26b67e09c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5682d9a10106f8f46cf91f69e93820ea37034c65e0b703b737c96fd34f1382b6 -size 41916 +oid sha256:ed553e5a7f4b923f686da6a4411529b801e0e78fa627b5ffdeecd6a161a72d9a +size 43844 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png index 03634dcfc5b..aefb84eef1d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceViewUnified_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d2347b612d5b4b27fd08979a9d577c73533e1c700f7ff3ebe75c4826445edac -size 42623 +oid sha256:f793e5e0c6d0c59271e91f2859de91258b12bb1be9ae070f5c83da4ba831d111 +size 45619 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png index 8d706582d04..e5c8704b684 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e4fb3b9d2d8ad8037ef22bb83fc6b088219c4340959535c17620f9e6cde4666 -size 5038 +oid sha256:eeb01b15f9fe74f17b31013674d7f8ce52e485780431e0b52898a83141d4ef77 +size 5207 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png index 48fd9185d06..7d03d87f791 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cf2332b8aa0c96fb317fb026fdc4eecd7d00fe0c29c0b94990f07e3606a3783 -size 8570 +oid sha256:67d30d074399af52222b50d08650c420cbb76c98840158cf7621e9eb55b6a81b +size 8368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png index 17c195a8666..eba4c7f6e7d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6c8d86a2fe8b006a62563ca79cff665c096b5f2fe0e5adb4b266669c04733cc -size 8743 +oid sha256:fe9f5514c4eaed79d712893cc37a8d63b9cb3deee2938469deba28fb5c9b3afa +size 8827 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png index 03593c8be06..2d250955b78 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce3c1b06c4d48a5f6cda97dc8a59b3d2c2686e783c2eb27d527c510b86297ae8 -size 8701 +oid sha256:2c17bcb5d8af655ed397c1a2f378a7e63ff7af4f5418ce23ed1083dadc2d5ebf +size 8465 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png index 5c2b5ab86d4..2f62ccfb229 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77f69bf2e88293341ef6bde0a37db1fcf47a53f4f9f4ff99cd0bc16d3a9abdde -size 8994 +oid sha256:62cb8d36c0b7ff29d19b76f6c8225beb60912cae3d58281f4fd60f9f37641e87 +size 8789 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png index 73013740305..6a70f3e1c70 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2b5e606969e3721467e80cd50c5155a21f1b3539bebb23ea6797a3d3c90ee4b -size 8995 +oid sha256:812a88c57c59a68a394d8a35dc5d5ea88b4ba0ecf9845b57262260c30d1a245d +size 8836 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png index ee008caa240..a867f7175ee 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b56f8042f4fdd691c4cbfbcd8b78c403508e9e891c1d8875a3caf681c0beadd9 -size 4875 +oid sha256:7761e645c471bccca171bec10a91e483b8b980938dba794721b22261e5a92e6b +size 5459 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png index baad6948763..99a3aa27839 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a5862175b39e1545678f40aaf22b8096073c2d830baf2d479ab624348770537 -size 4977 +oid sha256:bb1ea225a0793930b51886c91c85ff5fc64f6d7ba5466099a644178edabc21c2 +size 5194 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png index 6f23fd6ec5c..2b0c60caf1c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28e8f99576009e5ecd9f16c2392dc55d4c32395814d1726310445f98e03172aa -size 5236 +oid sha256:b7765de29d007d0e911bbae1350951d4628a733ce02e784c13607856df569ac9 +size 5406 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png index 5acd1940c6f..8bdee15425c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49e123bd5943a6e24015433896b131badc02e820177869d882909e172cc5f739 -size 5285 +oid sha256:9be8e55d9d789d81ad9288f4a824495dcf17185be3ef55867b5272a91e0e7274 +size 5499 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png index 2b6979fbca7..cdd1cce35df 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f91b7d0f786b2fdf153e65a2e102c71f6294bada41b95df0425f26583e878ef -size 5545 +oid sha256:a76060c9b9b09997190685a03954964b251e95ac8a409e50b6a72e864b6680ed +size 5721 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png index 264974ea7cc..f492623c13d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:047395ca72dafa655ebc7e1b84bb399529a500199314e0ff66015bc349eb6e6f -size 5428 +oid sha256:098c2d444a17049aa6e2fab73c885f8575855e55c5c9949043a3dbb1176da523 +size 6009 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png index 0d2b911076b..fd679ed1802 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cee09dcb3ad9366b4633860285549ccd1713e6d98507f52ffd5013dd8d384c9 -size 5497 +oid sha256:2e6dc10827ccedcf99b5c0baeab7f35cbf79320c3993668d618b7669d10acd8c +size 5711 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png index 99e0d3fa47e..259f1f84056 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a2423be28975edc05e2c56413c59eb8cb010ced6614e1a5d6d633adf90fdd69 -size 5774 +oid sha256:312c5c262860a15bcdcd60023acb220a3bcd6b552cb681b8d1fad97795f4be89 +size 5959 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png index a6a21cef1ef..39b0a5fdb59 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5a4252643772938927b16b971d5c42f7f464bd546369c3facf2f529636edafa -size 5837 +oid sha256:ecb4e8c1a8a38f0fa4b2bd0b613f6383fa2624af0cfed003404e906afaf8fbdd +size 6098 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png index 5cb98963a60..82cd02645fb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:277a0c0727b356bc26a67a393104a65c9ab189b1a3429102fa825b1cd74c90aa -size 5045 +oid sha256:9ddf7e8dbc365a97484e409128b17d805014019ef7ba4689d1748754b172fe24 +size 5276 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png index 18675b534a2..4e50229d946 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f956d02e8e7e0f395e3c8208738ff78fc17241441ef537c49350ff08d5a07e3d -size 8274 +oid sha256:46d66244a5089bc9ef0978775feac606c4dcc885d7b4e28209c240a596b945c0 +size 8147 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png index 78caad8dbe0..c18752a0ccb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:287dc73fe7edd7910acc9af2bd7d2e6b9b5cb331d57aab120ea0ed781d70bd66 -size 8479 +oid sha256:5610c7e73335e38e7dfb6383342bd2ef77d4b703c39d616bb32865aa049c1472 +size 8739 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png index d97e9f5923e..e0938b70d46 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f97efaae6da5b61a450ca5203305bf527b18585a1c16580800771aaf3c4f785 -size 8565 +oid sha256:a35532200a8d13a500b4e855b5232cb51333ef7cc21d2aea753d4e4e72a55e15 +size 8401 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png index 3b4a953d8b8..f1b58239f99 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b9a4ec52afe29ce64afd43d57121960f1a447c98160f3f23ae916d17064cd46 -size 8611 +oid sha256:e2a70fac799f703627fcad12144ab5be0a329f66b1ee8b3b701a1fbc0a4b0d01 +size 8498 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png index 2dec80699b8..bfa594a0ee0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96f3613893288888180e82791ba32aba6ded720011651ede443d75fb7c27e0c8 -size 8615 +oid sha256:c30d37bd3d271749b7a1e2797734252c207c856ffe26c067d85765dd1e0208da +size 8505 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png index 411764eb933..41174daf225 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fce09b71f1bdf430e4ece8901e6362a8d7e226b8e60bcc18e1ef55000ad0da9 -size 4869 +oid sha256:055d6cc80406eae235df8963e3f4d1297c3289e15e98c39110eabc608bd52edf +size 5537 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png index 552e3f36eb2..cc606fbff81 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e80046f10f3439630343725aa7ecc867bc1a421f49fd54011d2380d519de607d -size 5004 +oid sha256:fd9647075ce3c319a3314c1a2ee65e5cb25e4c5ce2ccf468f740ce6be77b73c1 +size 5292 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png index 9b1489406f4..4cbe91bbca6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:150ee262a0b7d06b39328addce5bf1c10a64a2477a583dc47804c59be515209a -size 5218 +oid sha256:f365b2e728e35938bad04af6d559c4116e0df2acac68965d7e893108379c7e86 +size 5525 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png index fab507d43ce..e2973bc33d0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c77636d0ecc552d5ec06372835679e6ed4af75a51c8e76940df867899d64872 -size 5248 +oid sha256:1a5cfac43245635159a5ac31b65a4b8b1da26a4f8228b0b7f84ab0849975bdef +size 5559 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png index 114907d5e77..993e4db9c62 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c38829db94f323aa5f22bda82a3ea9805c249659c3ab691ddbbfb7cdca22226 -size 5561 +oid sha256:0fd1b21bb8111004d192f64e16204465e1ada5b03a92d7727b2fbcd45789a634 +size 5792 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png index 665b1e285ae..9347ba6ebc3 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d23db38825014c1c84dfa0616a6f8e41e60331a7e1fa7d4cda10504780add378 -size 5439 +oid sha256:67f62cd7e6d63a03fe4a0c5a10fe0444dc4dc941bdf363172ca3a464d3964443 +size 6092 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png index a81d629580b..5277d0d9c08 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73028e20dbd8c090887a63c98373cf06041d38ecd2680711bbfff71ca2288234 -size 5524 +oid sha256:c82921ec225d5e667e6419f16b13594b59d180832b279433a7c272f955581df9 +size 5777 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png index 3d9a261fcea..e31e556db16 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0721ec5ad0b074e539f90c025cfe210f14313f89b0fd462820c0e9a61c923dba -size 5738 +oid sha256:8b9bf0915a171539260f552ef4788f8107a211fa5cbbf1f2ce43c0d79836881f +size 6026 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png index fdff6a63e37..0234b1d6573 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:916d9b4af8143da8383e46024ec8249afa3c88a4bc141403fe9ee64145513b43 -size 5821 +oid sha256:18c36a2e97244d24df454a6364f2db78fb1e8039693d536d53a79bb98134f882 +size 6134 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 index d20bfd93cf0..18319e718fb 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c4339bfe7cc578ac0285e86c38327dc3b2e55de1c9d56817c0574804febec65 -size 611864 +oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74 +size 606823