Content scanner (Element Pro): scan timeline events for unsafe content (#7159)

* Simplify how event content with captions are displayed

The caption component will now be added in `TimelineItemEventContentView` instead of separately in each `TimelineItem__View` component

* Make sure `CoilMediaFetcher` methods to fetch media throw errors

We need to catch them later to check if the content scanner marked some thumbnails or medias as invalid/unsafe in `AsyncImage` instances

* Add a `ContentScannerService` that allows us to manually scan a list of medias in an event and then update and store the results

The results take the form of `ContentValidationValue`, stored in a `ContentValidationState` instance tied to the event

* Create `EventContentValidationCache` to keep the `ContentValidationStates` for events in a room

* Expose `EventContentValidationCache` to Compose using `LocalEventContentValidationState`

Also add a couple of helper functions to avoid having to manually remember the states

* Use the new components to display invalid content in the timeline items

* Add `blurhash` to most media items so we can use it while validating the thumbnails/medias

* Fix custom layout issues with screenshot and UI tests

* Also check the validation state for replied-to events in the composer

* Fix and add tests

* Handle unrecoverable content validation errors too: display a 'not found' UI

* Improve the performance of `EqualWidthColumn` for the single item case

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-07-16 09:59:51 +02:00
committed by GitHub
parent 56cf00560e
commit 0d03472c60
241 changed files with 2881 additions and 828 deletions
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.api
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
/**
* Service to perform security scans in the contents for a given event and media source.
*
* The default implementation (FOSS) always returns a valid state.
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/
fun interface ContentScannerService {
fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
}
@@ -32,4 +32,5 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
} }
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources.
*/
class DefaultContentScannerService(
private val contentScanner: ContentScanner,
private val coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ContentScannerService {
private val context = coroutineDispatchers.io.limitedParallelism(4)
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
val currentState = contentValidationState.getCurrentMediaState(url)
if (currentState != ContentValidationValue.Unknown) continue
contentValidationState.update(url, ContentValidationValue.Loading)
coroutineScope.launch(context) {
contentScanner.scan(mediaSource)
.onSuccess { isValid ->
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
contentValidationState.update(url, contentValidationValue)
}
.onFailure { exception ->
if (exception is IOException) {
// If it's an IO-related exception, we can retry later, so we don't store the error
contentValidationState.update(url, ContentValidationValue.Unknown)
} else {
// For other exceptions, we cache the failure
contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception))
}
}
}
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultEventContentValidationCache(
private val contentScanner: ContentScanner?,
) : EventContentValidationCache {
private val cache = ConcurrentHashMap<EventId, ContentValidationState>()
override operator fun get(eventId: EventId): ContentValidationState {
return cache.getOrPut(eventId) {
if (contentScanner != null) {
DefaultContentValidationState()
} else {
Timber.v("Content scanner is not available, returning NoopContentValidationState for eventId: $eventId")
NoopContentValidationState()
}
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl.di
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/**
* Noop implementation of [ContentScannerService] that always returns success.
*
* This is used when the content scanner feature is not enabled or available.
*/
class AlwaysValidContentScannerService : ContentScannerService {
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
// Always return success for the noop implementation
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
contentValidationState.update(url, ContentValidationValue.Valid)
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object ContentScannerModule {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
contentScanner: ContentScanner?,
@RoomCoroutineScope coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
): ContentScannerService {
return if (contentScanner != null) {
DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
} else {
AlwaysValidContentScannerService()
}
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.FileNotFoundException
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultContentScannerServiceTest {
@Test
fun `scan with no media sources returns valid`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
eventId = AN_EVENT_ID,
mediaSources = emptyList(),
contentValidationState = contentValidationState,
)
scanLambda.assertions().isNeverCalled()
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid)
}
@Test
fun `scan with single media source`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan with several media sources`() = runTest {
val mediaSourceA = aMediaSource("https://example.com/mediaA.jpg")
val mediaSourceB = aMediaSource("https://example.com/mediaB.jpg")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { source ->
when (source) {
mediaSourceA -> Result.success(true)
mediaSourceB -> Result.success(false)
else -> error("Unexpected media source: $source")
}
}
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSourceA, mediaSourceB),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledExactly(2)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceA.safeUrl).first()).isEqualTo(ContentValidationValue.Valid)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceB.safeUrl).first()).isEqualTo(ContentValidationValue.Invalid)
assertThat(contentValidationState.overallStateFlow.first()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which are already being scanned`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
// First scan attempt
service.scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
// Immediately after, a new scan attempt is made for the same media source
service.scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
// The scan action was done just once, and the overall state is invalid as expected
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which already have a cached value`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = NoopContentValidationState(ContentValidationValue.Valid)
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isNeverCalled()
}
@Test
fun `if a scan fails with a recoverable error, the content validation value will be back to Unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(FileNotFoundException("Some IO exception")) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.Unknown)
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
fun `if a scan fails with an unrecoverable error, the content validation value will be back to UnrecoverableError`() = runTest {
val error = OutOfMemoryError("BOOM")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(error) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
eventId = AN_EVENT_ID,
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.UnrecoverableError(error))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.UnrecoverableError(error))
}
private fun TestScope.createDefaultContentScannerService(
contentScanner: ContentScanner = FakeContentScanner(),
sessionCoroutineScope: CoroutineScope = backgroundScope,
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): DefaultContentScannerService {
return DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import org.junit.Test
class DefaultEventContentValidationCacheTest {
@Test
fun `get returns NoopContentValidationState when ContentScanner is null`() {
// Given
val contentScanner: ContentScanner? = null
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(NoopContentValidationState::class.java)
}
@Test
fun `get returns an actual DefaultContentValidationState when ContentScanner is not null`() {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(DefaultContentValidationState::class.java)
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl.di
import com.google.common.truth.Truth.assertThat
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ContentScannerModuleTest {
@Test
fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest {
// Given
val contentScanner: ContentScanner? = null
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(AlwaysValidContentScannerService::class.java)
}
@Test
fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(DefaultContentScannerService::class.java)
}
}
+1
View File
@@ -62,6 +62,7 @@ dependencies {
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
@@ -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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.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.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
@@ -155,6 +156,7 @@ class MessagesFlowNode(
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean, val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@@ -377,6 +379,7 @@ class MessagesFlowNode(
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
) )
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -828,6 +831,7 @@ class MessagesFlowNode(
mediaSource = galleryItem.mediaSource, mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource, thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(), type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
) )
}.reversed() }.reversed()
NavTarget.GalleryViewer( NavTarget.GalleryViewer(
@@ -861,6 +865,7 @@ class MessagesFlowNode(
mediaSource = attachment.mediaSource, mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource, thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File, type = GalleryItemData.Type.File,
blurHash = null,
) )
}.reversed() }.reversed()
NavTarget.GalleryViewer( NavTarget.GalleryViewer(
@@ -920,6 +925,7 @@ class MessagesFlowNode(
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay, canUseOverlay = canUseOverlay,
blurHash = content.blurHash(),
) )
} }
@@ -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.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline 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.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.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -97,6 +99,7 @@ class MessagesNode(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val focusedEventId: EventId?, val focusedEventId: EventId?,
@@ -261,6 +264,7 @@ class MessagesNode(
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) { ) {
val state = presenter.present() val state = presenter.present()
@@ -123,6 +123,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState 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.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.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@@ -148,6 +150,8 @@ fun MessagesView(
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit, knockRequestsBannerView: @Composable () -> Unit,
) { ) {
val eventContentValidationState = LocalEventContentValidationState.current
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
} }
@@ -170,6 +174,9 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) { if (hideKeyboard) {
localView.hideKeyboard() localView.hideKeyboard()
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction 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.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment 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.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.use 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.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.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.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.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
private val notificationConversationService: NotificationConversationService, private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService, private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -848,6 +855,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true) setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
} }
else -> { 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. // When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) { if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState) setText("", markdownTextEditorState, richTextEditorState)
@@ -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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -290,9 +289,9 @@ private fun TimelineItemEventContentViewWrapper(
) )
} else { } else {
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration import kotlin.time.Duration
sealed interface TimelineEvent { sealed interface TimelineEvent {
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
) : TimelineItemPollEvent ) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent data object StopLiveLocationShare : TimelineItemEvent
data class ValidateMedia(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState
) : TimelineItemEvent
} }
@@ -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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel 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.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.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager 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 io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -98,6 +101,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@@ -157,6 +161,8 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
} }
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
is TimelineEvent.LoadMore -> { is TimelineEvent.LoadMore -> {
@@ -252,6 +258,9 @@ class TimelinePresenter(
focusedEventId = event.focusedEvent, focusedEventId = event.focusedEvent,
) )
} }
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSources, event.validationState))
}
} }
} }
@@ -259,7 +268,7 @@ class TimelinePresenter(
timelineItemsFactory.timelineItems timelineItemsFactory.timelineItems
.onEach { newTimelineItems -> .onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems) timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems timelineItems = newTimelineItems.toImmutableList()
analyticsService.run { analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems) finishLongRunningTransaction(DisplayFirstTimelineItems)
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
enum class ContentPadding { enum class ContentPadding {
Textual, Textual,
Media, Media,
CaptionedMedia CaptionedMedia,
InvalidContent,
} }
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape 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.graphics.layer.CompositingStrategy
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -62,6 +66,8 @@ fun MessageEventBubble(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
customBackgroundColor: Color? = null,
borderColor: Color? = null,
content: @Composable BoxScope.() -> Unit = {}, content: @Composable BoxScope.() -> Unit = {},
) { ) {
val clickableModifier = if (isTalkbackActive()) { val clickableModifier = if (isTalkbackActive()) {
@@ -79,10 +85,12 @@ fun MessageEventBubble(
val cutTopStart = state.cutTopStart val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine) val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) } val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.drawWithCache { .drawWithCache {
@@ -103,6 +111,9 @@ fun MessageEventBubble(
// Then draw the content on top of it // Then draw the content on top of it
drawContent() 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 // And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) { if (cutTopStart) {
drawCircle( drawCircle(
@@ -0,0 +1,320 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
private val AN_EVENT_ID = EventId($$"$eventId")
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemFileViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAudioViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
ElementTimelineItemPreview {
CompositionLocalProvider(
LocalEventContentValidationState provides cache,
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM"))))
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(mediaSource = MediaSource("error", "{}")),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
)
)
)
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemAttachmentsContent(
attachments = listOf(
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
anAttachmentItem(),
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("error", "{}")),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
)
)
)
)
}
}
private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = ImageMessageType(
filename = "A file",
caption = "A caption",
formattedCaption = null,
source = MediaSource("", ""),
info = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null)
)
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = EventId($$"$text_eventId"),
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "A body",
formatted = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null)
)
@@ -33,9 +33,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -88,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@@ -122,9 +124,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName 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.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.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView 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.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName 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.matrix.ui.messages.sender.SenderNameMode
@@ -176,12 +182,12 @@ fun TimelineItemEventRow(
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), timelineProtectionState = timelineProtectionState,
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
@@ -468,6 +474,23 @@ private fun TimelineItemEventRowContent(
) )
} }
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
.collectOverallState()
val needsInvalidContentCustomisations =
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
currentContentValidationState.hasError() &&
event.content.isMedia
// If the event has a dangerous media content we need to set custom message bubble background and border colors
val themeColors = ElementTheme.colors
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
background to border
}
// Message bubble // Message bubble
val bubbleState = BubbleState( val bubbleState = BubbleState(
groupPosition = event.groupPosition, groupPosition = event.groupPosition,
@@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onContentClick, onClick = onContentClick,
onLongClick = onLongClick, onLongClick = onLongClick,
customBackgroundColor = dangerousContentBubbleColor,
borderColor = borderColor,
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
@@ -747,6 +772,7 @@ private fun MessageEventBubbleContent(
} }
ContentPadding.CaptionedMedia -> ContentPadding.CaptionedMedia ->
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp) Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
} }
val threadDecoration = @Composable { val threadDecoration = @Composable {
@@ -770,10 +796,13 @@ private fun MessageEventBubbleContent(
} }
val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
.collectOverallState()
val topPadding = if (showThreadDecoration) 0.dp else 8.dp val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val shape = RoundedCornerShape(6.dp)
val inReplyToModifier = Modifier val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp)) .clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) { val talkbackCompatModifier = if (isTalkbackActive()) {
// Use z-index to make the replied to text being read after the message // Use z-index to make the replied to text being read after the message
@@ -782,14 +811,25 @@ private fun MessageEventBubbleContent(
} else { } else {
inReplyToModifier.clickable(onClick = inReplyToClick) inReplyToModifier.clickable(onClick = inReplyToClick)
} }
val contentHasError = currentContentValidationState.hasError()
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box( Box(
modifier = talkbackCompatModifier modifier = talkbackCompatModifier
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) .border(1.dp, borderColor, shape)
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp)) .background(backgroundColor, shape)
.padding(4.dp) .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( InReplyToView(
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
contentValidationValue = currentContentValidationState,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
) )
} }
@@ -809,30 +849,47 @@ private fun MessageEventBubbleContent(
} }
} }
val timestampPosition = when (val content = event.content) { val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay val needsInvalidContentLayout =
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay // Gallery events should not apply custom paddings or layout dispositions
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below event.content !is TimelineItemGalleryContent &&
is TimelineItemAttachmentsContent -> TimestampPosition.Below event.content !is TimelineItemAttachmentsContent &&
is TimelineItemStickerContent -> TimestampPosition.Overlay contentValidationState.hasError()
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation() val timestampPosition = if (needsInvalidContentLayout) {
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && // The invalid content view will be displayed in all these cases, independent of the event content
content.mode.isActive && TimestampPosition.Aligned
content.mode.isOwnUser } else {
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
} }
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
} }
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media val paddingBehaviour = if (needsInvalidContentLayout) {
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media ContentPadding.InvalidContent
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia } else {
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia when (event.content) {
is TimelineItemStickerContent, is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemLocationContent -> ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
else -> ContentPadding.Textual is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
} }
CommonLayout( CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse, showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
@@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -62,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
@@ -134,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
@@ -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.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.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.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.designsystem.colors.gradientSubtleColors import io.element.android.libraries.designsystem.colors.gradientSubtleColors
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
@@ -77,9 +76,9 @@ internal fun TimelineItemRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) }, onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) }, onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) }, onLongClick = { onLongClick(event) },
@@ -119,6 +118,7 @@ internal fun TimelineItemRow(
onClick = { onContentClick(timelineItem) }, onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick, onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
timelineProtectionState = timelineProtectionState,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts 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.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.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -43,6 +45,7 @@ fun TimelineItemStateEventRow(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit, onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
timelineProtectionState: TimelineProtectionState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -67,11 +70,11 @@ fun TimelineItemStateEventRow(
.widthIn(max = 320.dp) .widthIn(max = 320.dp)
) { ) {
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
hideMediaContent = false, timelineProtectionState = timelineProtectionState,
onShowContentClick = {},
eventSink = eventSink, eventSink = eventSink,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {}, onGalleryItemClick = {},
@@ -107,6 +110,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onReadReceiptsClick = {}, onReadReceiptsClick = {},
timelineProtectionState = aTimelineProtectionState(),
eventSink = {} eventSink = {}
) )
} }
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,8 +24,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout 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.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.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/** /**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView. * package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
@@ -38,9 +39,9 @@ fun TimelineItemAttachmentView(
iconContentDescription: String?, iconContentDescription: String?,
filename: String, filename: String,
fileExtensionAndSize: String, fileExtensionAndSize: String,
caption: String?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@@ -50,16 +51,9 @@ fun TimelineItemAttachmentView(
iconContentDescription = iconContentDescription, iconContentDescription = iconContentDescription,
filename = filename, filename = filename,
fileExtensionAndSize = fileExtensionAndSize, fileExtensionAndSize = fileExtensionAndSize,
hasCaption = caption != null, isLoading = !contentValidationValue.isValidated(),
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
) )
if (caption != null) {
TimelineItemAttachmentCaptionView(
modifier = Modifier.padding(top = 4.dp),
caption = caption,
onContentLayoutChange = onContentLayoutChange,
)
}
} }
} }
@@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView(
iconContentDescription: String?, iconContentDescription: String?,
filename: String, filename: String,
fileExtensionAndSize: String, fileExtensionAndSize: String,
hasCaption: Boolean, isLoading: Boolean,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -86,12 +80,19 @@ private fun TimelineItemAttachmentHeaderView(
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)), .background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( if (isLoading) {
imageVector = icon, CircularProgressIndicator(
contentDescription = iconContentDescription, modifier = Modifier.size(24.dp),
tint = ElementTheme.colors.iconPrimary, color = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp), )
) } else {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
}
} }
Column { Column {
Text( Text(
@@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView(
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
onTextLayout = if (hasCaption) { onTextLayout = ContentAvoidingLayout.measureLastTextLine(
{} onContentLayoutChange = onContentLayoutChange,
} else { extraWidth = iconSize + spacing
ContentAvoidingLayout.measureLastTextLine( ),
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
},
) )
} }
} }
} }
@Composable
private fun TimelineItemAttachmentCaptionView(
caption: String,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = caption,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
)
)
}
@@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<Timeline
), ),
aTimelineItemAttachmentsContent( aTimelineItemAttachmentsContent(
body = "Files", body = "Files",
caption = "Files mixed with media",
attachments = listOf( attachments = listOf(
anAttachmentItem( anAttachmentItem(
filename = "report.pdf", filename = "report.pdf",
@@ -7,31 +7,30 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -39,8 +38,6 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.AttachmentItem import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
@@ -50,66 +47,124 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon 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.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText import kotlin.math.max
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemAttachmentsListView( fun TimelineItemAttachmentsListView(
eventId: EventId?,
content: TimelineItemAttachmentsContent, content: TimelineItemAttachmentsContent,
onGalleryItemClick: (Int) -> Unit, onGalleryItemClick: (Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { val contentValidationState = rememberEventContentValidationState(
Column( eventId = eventId,
modifier = Modifier.fillMaxWidth(), needsValidation = content.isMedia,
) { )
content.attachments.forEachIndexed { index, attachment ->
Column { Column(modifier = modifier.fillMaxWidth()) {
if (index > 0) { val validationStates = remember(content.attachments) {
HorizontalDivider( buildList {
color = ElementTheme.colors.borderInteractiveSecondary, for (attachment in content.attachments) {
add(validationStateForAttachment(attachment, contentValidationState))
}
}.toMutableStateList()
}
content.attachments.forEachIndexed { index, attachment ->
Column(modifier = Modifier.fillMaxWidth()) {
val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl)
val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
validationStateForAttachment(attachment, contentValidationState)
}
LaunchedEffect(itemContentValidationState) {
validationStates[index] = itemContentValidationState
}
val needsSeparator = index in 1..max(1, content.attachments.lastIndex)
val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true
if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
when (itemContentValidationState) {
ContentValidationValue.Invalid -> {
val shape = RoundedCornerShape(6.dp)
InvalidContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
) )
} }
AttachmentListItem( is ContentValidationValue.UnrecoverableError -> {
attachment = attachment, val shape = RoundedCornerShape(6.dp)
onClick = { onGalleryItemClick(index) }, NotFoundContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
)
}
ContentValidationValue.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally))
}
ContentValidationValue.Valid -> {
AttachmentListItem(
attachment = attachment,
onClick = { onGalleryItemClick(index) },
)
}
else -> {
// TODO handle cases where either the content hasn't started validation or it failed to do so
}
}
if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
) )
} }
} }
} }
if (content.showCaption) { }
HorizontalDivider( }
color = ElementTheme.colors.borderInteractiveSecondary,
) private fun validationStateForAttachment(
Spacer(modifier = Modifier.height(8.dp)) attachment: AttachmentItem,
val caption = if (LocalInspectionMode.current) { contentValidationState: ContentValidationState,
SpannedString(content.caption) ): ContentValidationValue {
} else { val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown
content.formattedCaption ?: SpannedString(content.caption) val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl)
} return validationStateForAttachment(thumbnailValue, mediaValue)
CompositionLocalProvider( }
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular private fun validationStateForAttachment(
) { thumbnailValue: ContentValidationValue,
EditorStyledText( mediaValue: ContentValidationValue,
modifier = Modifier ): ContentValidationValue {
.padding(horizontal = 4.dp) return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) {
.widthIn(min = 120.dp), ContentValidationValue.Invalid
text = caption, } else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) {
style = ElementRichTextEditorStyle.textStyle(), listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() }
onLinkClickedListener = onLinkClick, } else if (thumbnailValue.isLoading() || mediaValue.isLoading()) {
onLinkLongClickedListener = onLinkLongClick, ContentValidationValue.Loading
releaseOnDetach = false, } else {
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), mediaValue
)
}
}
} }
} }
@@ -213,10 +268,8 @@ internal fun TimelineItemAttachmentsListViewPreview(
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
) = ElementPreview { ) = ElementPreview {
TimelineItemAttachmentsListView( TimelineItemAttachmentsListView(
eventId = EventId("\$eventId"),
content = content, content = content,
onGalleryItemClick = {}, onGalleryItemClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@@ -16,21 +16,23 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
@Composable @Composable
fun TimelineItemAudioView( fun TimelineItemAudioView(
content: TimelineItemAudioContent, content: TimelineItemAudioContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) { ) {
TimelineItemAttachmentView( TimelineItemAttachmentView(
icon = CompoundIcons.Audio(), icon = CompoundIcons.Audio(),
iconContentDescription = null, iconContentDescription = null,
filename = content.filename, filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize, fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier, modifier = modifier,
contentValidationValue = contentValidationValue,
) )
} }
@@ -8,9 +8,23 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter import io.element.android.features.messages.impl.timeline.di.rememberPresenter
@@ -32,132 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
eventId: EventId?,
content: TimelineItemEventContent, content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit), onGalleryItemClick: ((Int) -> Unit),
timelineProtectionState: TimelineProtectionState,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) { ) {
val presenterFactories = LocalTimelineItemPresenterFactories.current val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) {
when (content) { timelineProtectionState.hideMediaContent(eventId)
is TimelineItemEncryptedContent -> TimelineItemEncryptedView( }
content = content,
onContentLayoutChange = onContentLayoutChange, val onShowContentClick = remember(timelineProtectionState.eventSink) {
modifier = modifier {
) timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId))
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
modifier = modifier,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLocationContent -> {
TimelineItemLocationView(
content = content.ensureActiveLiveLocation(),
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
modifier = modifier
)
} }
is TimelineItemImageContent -> TimelineItemImageView( }
content = content,
hideMediaContent = hideMediaContent, val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia)
onContentClick = onContentClick, val overallValidationState by contentValidationState.collectOverallState()
onLongClick = onLongClick, val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent }
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick, if (eventId != null) {
onLinkLongClick = onLinkLongClick, ValidateMediaHelper(eventId, content, contentValidationState, eventSink)
onContentLayoutChange = onContentLayoutChange, }
modifier = modifier,
) val caption = content.captionOrNull()
is TimelineItemGalleryContent -> TimelineItemGalleryView( val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent
content = content, // If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content
onGalleryItemClick = { index -> onGalleryItemClick(index) }, val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) {
onLongClick = onLongClick, if (showCaption) {
onLinkClick = onLinkClick, {}
onLinkLongClick = onLinkLongClick, } else {
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange
modifier = modifier, }
) }
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
content = content, val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem
onGalleryItemClick = { index -> onGalleryItemClick(index) }, val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem
onLinkClick = onLinkClick, EqualWidthColumn(modifier = modifier) {
onLinkLongClick = onLinkLongClick, if (displayInvalidContent) {
onContentLayoutChange = {}, InvalidContentView(
modifier = modifier, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
) onTextLayout = ContentAvoidingLayout.measureLastTextLine(
is TimelineItemStickerContent -> TimelineItemStickerView( onContentLayoutChange = calculatedOnContentLayoutChange,
content = content, // Icon + horizontal paddings
hideMediaContent = hideMediaContent, extraWidth = 24.dp + 20.dp,
onContentClick = onContentClick, )
onLongClick = onLongClick, )
onShowClick = onShowContentClick, } else if (displayContentNotFound) {
modifier = modifier, NotFoundContentView(
) contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
is TimelineItemVideoContent -> TimelineItemVideoView( onTextLayout = ContentAvoidingLayout.measureLastTextLine(
content = content, onContentLayoutChange = calculatedOnContentLayoutChange,
hideMediaContent = hideMediaContent, // Icon + horizontal paddings
onContentClick = onContentClick, extraWidth = 24.dp + 20.dp,
onLongClick = onLongClick, )
onShowContentClick = onShowContentClick, )
onLinkClick = onLinkClick, } else {
onLinkLongClick = onLinkLongClick, val presenterFactories = LocalTimelineItemPresenterFactories.current
onContentLayoutChange = onContentLayoutChange, when (content) {
modifier = modifier is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
) content = content,
is TimelineItemFileContent -> TimelineItemFileView( onContentLayoutChange = calculatedOnContentLayoutChange,
content = content, )
onContentLayoutChange = onContentLayoutChange, is TimelineItemRedactedContent -> TimelineItemRedactedView(
modifier = modifier content = content,
) onContentLayoutChange = calculatedOnContentLayoutChange,
is TimelineItemAudioContent -> TimelineItemAudioView( )
content = content, is TimelineItemTextBasedContent -> TimelineItemTextView(
onContentLayoutChange = onContentLayoutChange, content = content,
modifier = modifier onLinkClick = onLinkClick,
) onLinkLongClick = onLinkLongClick,
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) onContentLayoutChange = calculatedOnContentLayoutChange,
is TimelineItemStateContent -> TimelineItemStateView( )
content = content, is TimelineItemUnknownContent -> TimelineItemUnknownView(
modifier = modifier content = content,
) onContentLayoutChange = calculatedOnContentLayoutChange,
is TimelineItemPollContent -> TimelineItemPollView( )
content = content, is TimelineItemLocationContent -> {
eventSink = eventSink, TimelineItemLocationView(
modifier = modifier, content = content.ensureActiveLiveLocation(),
) onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
is TimelineItemVoiceContent -> { )
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content) }
TimelineItemVoiceView( is TimelineItemImageContent -> {
state = presenter.present(), TimelineItemImageView(
content = content, content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemStickerContent -> {
TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemVideoContent -> {
TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemGalleryContent -> TimelineItemGalleryView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
)
is TimelineItemFileContent -> {
TimelineItemFileView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemAudioContent -> {
TimelineItemAudioView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView()
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
eventSink = eventSink,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
}
}
if (showCaption) {
val padding = if (displayInvalidContent) {
PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 0.dp)
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> PaddingValues(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 0.dp)
else -> PaddingValues(horizontal = 12.dp, vertical = 8.dp)
}
}
CaptionView(
modifier = Modifier.padding(padding),
caption = caption,
formattedCaption = content.formattedCaptionOrNull(),
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier
) )
} }
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") }
}
@Composable
private fun CaptionView(
caption: String,
formattedCaption: CharSequence?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val caption = if (LocalInspectionMode.current) {
SpannedString(caption)
} else {
formattedCaption ?: SpannedString(caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = modifier,
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
@Composable
private fun ValidateMediaHelper(
eventId: EventId,
content: TimelineItemEventContent,
contentValidationState: ContentValidationState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
) {
val mediaSources = when (content) {
is TimelineItemImageContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemStickerContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemVideoContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemFileContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemAudioContent -> listOf(content.mediaSource)
is TimelineItemVoiceContent -> listOf(content.mediaSource)
is TimelineItemGalleryContent -> content.items.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
is TimelineItemAttachmentsContent -> content.attachments.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
else -> return
}
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(eventId, mediaSources) {
updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSources, contentValidationState))
} }
} }
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
/** /**
@@ -27,15 +28,16 @@ fun TimelineItemFileView(
content: TimelineItemFileContent, content: TimelineItemFileContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) { ) {
TimelineItemAttachmentView( TimelineItemAttachmentView(
icon = CompoundIcons.Attachment(), icon = CompoundIcons.Attachment(),
iconContentDescription = stringResource(CommonStrings.common_file), iconContentDescription = stringResource(CommonStrings.common_file),
filename = content.filename, filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize, fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier, modifier = modifier,
contentValidationValue = contentValidationValue,
) )
} }
@@ -116,6 +116,7 @@ fun aGalleryItem(
width: Int = 400, width: Int = 400,
height: Int = 300, height: Int = 300,
duration: Duration = Duration.ZERO, duration: Duration = Duration.ZERO,
mediaSource: MediaSource = MediaSource(url = "", json = ""),
): GalleryItem { ): GalleryItem {
return GalleryItem( return GalleryItem(
filename = filename, filename = filename,
@@ -125,7 +126,7 @@ fun aGalleryItem(
GalleryItem.Type.File -> "application/pdf" GalleryItem.Type.File -> "application/pdf"
GalleryItem.Type.Image -> "image/jpeg" GalleryItem.Type.Image -> "image/jpeg"
}, },
mediaSource = MediaSource(url = "", json = ""), mediaSource = mediaSource,
thumbnailSource = null, thumbnailSource = null,
width = width, width = width,
height = height, height = height,
@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -21,28 +20,24 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
@@ -50,10 +45,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.utils.time.formatShort import io.element.android.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.collections.immutable.ImmutableList
private const val MAX_TILES = 5 private const val MAX_TILES = 5
@@ -67,12 +63,10 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp
@Composable @Composable
fun TimelineItemGalleryView( fun TimelineItemGalleryView(
eventId: EventId?,
content: TimelineItemGalleryContent, content: TimelineItemGalleryContent,
onGalleryItemClick: (Int) -> Unit, onGalleryItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val totalItems = content.items.size val totalItems = content.items.size
@@ -87,21 +81,25 @@ fun TimelineItemGalleryView(
when (totalItems) { when (totalItems) {
0 -> Unit 0 -> Unit
1 -> SingleItemLayout( 1 -> SingleItemLayout(
eventId = eventId,
item = content.items[0], item = content.items[0],
onClick = { onGalleryItemClick(0) }, onClick = { onGalleryItemClick(0) },
onLongClick = onLongClick, onLongClick = onLongClick,
) )
2 -> TwoItemLayout( 2 -> TwoItemLayout(
eventId = eventId,
items = content.items, items = content.items,
onItemClick = onGalleryItemClick, onItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
) )
3 -> ThreeItemLayout( 3 -> ThreeItemLayout(
eventId = eventId,
items = content.items, items = content.items,
onItemClick = onGalleryItemClick, onItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
) )
else -> FourPlusItemLayout( else -> FourPlusItemLayout(
eventId = eventId,
items = content.items, items = content.items,
showOverflow = showOverflow, showOverflow = showOverflow,
overflowCount = overflowCount, overflowCount = overflowCount,
@@ -110,40 +108,18 @@ fun TimelineItemGalleryView(
) )
} }
} }
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp)
.widthIn(min = 120.dp),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
} }
} }
@Composable @Composable
private fun SingleItemLayout( private fun SingleItemLayout(
eventId: EventId?,
item: GalleryItem, item: GalleryItem,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = item, item = item,
isLast = false, isLast = false,
remainingCount = 0, remainingCount = 0,
@@ -157,6 +133,7 @@ private fun SingleItemLayout(
@Composable @Composable
private fun TwoItemLayout( private fun TwoItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>, items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit, onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
@@ -167,6 +144,7 @@ private fun TwoItemLayout(
) { ) {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = item, item = item,
isLast = false, isLast = false,
remainingCount = 0, remainingCount = 0,
@@ -182,6 +160,7 @@ private fun TwoItemLayout(
@Composable @Composable
private fun ThreeItemLayout( private fun ThreeItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>, items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit, onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
@@ -191,6 +170,7 @@ private fun ThreeItemLayout(
verticalArrangement = Arrangement.spacedBy(GRID_SPACING), verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) { ) {
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = items[0], item = items[0],
isLast = false, isLast = false,
remainingCount = 0, remainingCount = 0,
@@ -206,6 +186,7 @@ private fun ThreeItemLayout(
) { ) {
for (it in 1..2) { for (it in 1..2) {
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = items[it], item = items[it],
isLast = false, isLast = false,
remainingCount = 0, remainingCount = 0,
@@ -222,6 +203,7 @@ private fun ThreeItemLayout(
@Composable @Composable
private fun FourPlusItemLayout( private fun FourPlusItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>, items: ImmutableList<GalleryItem>,
showOverflow: Boolean, showOverflow: Boolean,
overflowCount: Int, overflowCount: Int,
@@ -238,6 +220,7 @@ private fun FourPlusItemLayout(
) { ) {
for (it in 0..1) { for (it in 0..1) {
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = items[it], item = items[it],
isLast = false, isLast = false,
remainingCount = 0, remainingCount = 0,
@@ -260,6 +243,7 @@ private fun FourPlusItemLayout(
if (itemIndex < items.size) { if (itemIndex < items.size) {
val isOverflowItem = showOverflow && i == bottomRowItems - 1 val isOverflowItem = showOverflow && i == bottomRowItems - 1
GalleryItemCell( GalleryItemCell(
eventId = eventId,
item = items[itemIndex], item = items[itemIndex],
isLast = isOverflowItem, isLast = isOverflowItem,
remainingCount = if (isOverflowItem) overflowCount else 0, remainingCount = if (isOverflowItem) overflowCount else 0,
@@ -277,6 +261,7 @@ private fun FourPlusItemLayout(
@Composable @Composable
private fun GalleryItemCell( private fun GalleryItemCell(
eventId: EventId?,
item: GalleryItem, item: GalleryItem,
isLast: Boolean, isLast: Boolean,
remainingCount: Int, remainingCount: Int,
@@ -284,6 +269,22 @@ private fun GalleryItemCell(
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true)
val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl)
val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) {
ContentValidationValue.Invalid
} else if (thumbnailContentValidationState.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) {
listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError }
} else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) {
ContentValidationValue.Loading
} else {
mediaContentValidationState
}
}
Box( Box(
modifier = modifier modifier = modifier
.blurHashBackground(item.blurhash, alpha = 0.9f) .blurHashBackground(item.blurhash, alpha = 0.9f)
@@ -305,7 +306,20 @@ private fun GalleryItemCell(
VideoOverlay(duration = item.duration) VideoOverlay(duration = item.duration)
} }
if (isLast && remainingCount > 0) { if (itemContentValidationState.isLoading()) {
CircularProgressIndicator()
} else if (itemContentValidationState.hasError()) {
Box(
modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Error(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
} else if (isLast && remainingCount > 0) {
RemainingCountOverlay(count = remainingCount) RemainingCountOverlay(count = remainingCount)
} }
} }
@@ -374,11 +388,9 @@ internal fun TimelineItemGalleryViewPreview(
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
) = ElementPreview { ) = ElementPreview {
TimelineItemGalleryView( TimelineItemGalleryView(
eventId = null,
content = content, content = content,
onGalleryItemClick = {}, onGalleryItemClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@@ -8,20 +8,13 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -31,118 +24,97 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
private const val TALL_IMAGE_RATIO_DIVISOR = 3
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
hideMediaContent: Boolean, hideMediaContent: Boolean,
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) { ) {
val a11yLabel = stringResource(CommonStrings.common_image) val a11yLabel = stringResource(CommonStrings.common_image)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) { Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier.clip(RoundedCornerShape(10.dp)) Modifier.clip(RoundedCornerShape(10.dp))
} else { } else {
Modifier Modifier
} }
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally), modifier = containerModifier
.blurHashBackground(content.blurhash, alpha = 0.9f)
.align(Alignment.CenterHorizontally),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) { ) {
ProtectedView( if (isContentBeingValidated) {
hideContent = hideMediaContent, CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else { } else {
content.formattedCaption ?: SpannedString(content.caption) ProtectedView(
} hideContent = hideMediaContent,
CompositionLocalProvider( onShowClick = onShowContentClick,
LocalContentColor provides ElementTheme.colors.textPrimary, ) {
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular var isLoaded by remember { mutableStateOf(false) }
) { AsyncImage(
val width = content.width ?: 0 modifier = Modifier
val height = content.height ?: 0 .fillMaxWidth()
// if image is narrow and tall use DEFAULT_ASPECT_RATIO .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) { .then(
DEFAULT_ASPECT_RATIO if (!isTalkbackActive() && onContentClick != null) {
} else { Modifier
content.aspectRatio ?: DEFAULT_ASPECT_RATIO .combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { state ->
val url = content.thumbnailMediaRequestData.source?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
} }
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
} }
} }
} }
@@ -157,9 +129,6 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
onShowContentClick = {}, onShowContentClick = {},
onContentClick = {}, onContentClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@@ -172,43 +141,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
onShowContentClick = {}, onShowContentClick = {},
onContentClick = {}, onContentClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@PreviewsDayNight
@Composable
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Image with null aspectRatio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ATimelineItemEventRowPreview() = ElementPreview { internal fun ATimelineItemEventRowPreview() = ElementPreview {
@@ -220,9 +155,9 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
content = aTimelineItemImageContent( content = aTimelineItemImageContent(
filename = "image.jpg", filename = "image.jpg",
caption = "A long caption that may wrap into several lines", caption = "A long caption that may wrap into several lines",
width = 80, width = 40,
height = 300, height = 20,
aspectRatio = 80f / 300f, aspectRatio = 40f / 20f,
), ),
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
@@ -235,7 +170,7 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
filename = "image.jpg", filename = "image.jpg",
caption = "Narrow image with null aspectRatio", caption = "Narrow image with null aspectRatio",
width = 80, width = 80,
height = 300, height = 150,
aspectRatio = null, aspectRatio = null,
), ),
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider 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.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128 private const val STICKER_SIZE_IN_DP = 128
@@ -48,10 +53,14 @@ fun TimelineItemStickerView(
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowClick: () -> Unit, onShowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) { ) {
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
Column( Column(
modifier = modifier.semantics { contentDescription = description }, modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally),
) { ) {
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
@@ -63,11 +72,12 @@ fun TimelineItemStickerView(
hideContent = hideMediaContent, hideContent = hideMediaContent,
onShowClick = onShowClick, onShowClick = onShowClick,
) { ) {
var isLoaded by remember { mutableStateOf(false) } var isThumbnailLoaded by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier)
.then( .then(
if (onContentClick != null) { if (onContentClick != null) {
Modifier Modifier
@@ -91,9 +101,22 @@ fun TimelineItemStickerView(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { state ->
val url = content.preferredMediaSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isThumbnailLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
) )
} }
if (isContentBeingValidated) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
} }
} }
} }
@@ -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.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@@ -43,6 +44,9 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, 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 && val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis() content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when { val textStyle = when {
@@ -8,22 +8,17 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.modifiers.roundedBackground
@@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH 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.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemVideoView( fun TimelineItemVideoView(
@@ -75,15 +63,13 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) { ) {
val isTalkbackActive = isTalkbackActive() val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video) val a11yLabel = stringResource(CommonStrings.common_video)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) { Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier
.padding(top = 6.dp) .padding(top = 6.dp)
@@ -91,6 +77,9 @@ fun TimelineItemVideoView(
} else { } else {
Modifier Modifier
} }
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
@@ -127,48 +116,35 @@ fun TimelineItemVideoView(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { state ->
val url = content.thumbnailSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
) )
Box( if (!isContentBeingValidated) {
modifier = Modifier.roundedBackground(), Box(
contentAlignment = Alignment.Center, modifier = Modifier.roundedBackground(),
) { contentAlignment = Alignment.Center,
Image( ) {
imageVector = CompoundIcons.PlaySolid(), Image(
contentDescription = stringResource(id = CommonStrings.a11y_play), imageVector = CompoundIcons.PlaySolid(),
colorFilter = ColorFilter.tint(Color.White), contentDescription = stringResource(id = CommonStrings.a11y_play),
modifier = Modifier.semantics { hideFromAccessibility() } colorFilter = ColorFilter.tint(Color.White),
) modifier = Modifier.semantics { hideFromAccessibility() }
)
}
} else {
CircularProgressIndicator()
} }
} }
} }
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
} }
} }
@@ -181,9 +157,6 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onShowContentClick = {}, onShowContentClick = {},
onContentClick = {}, onContentClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@@ -196,39 +169,5 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onShowContentClick = {}, onShowContentClick = {},
onContentClick = {}, onContentClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
) )
} }
@PreviewsDayNight
@Composable
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "Video with null aspect ratio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -51,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
@@ -64,6 +65,7 @@ fun TimelineItemVoiceView(
content: TimelineItemVoiceContent, content: TimelineItemVoiceContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) { ) {
fun playPause() { fun playPause() {
state.eventSink(VoiceMessageEvent.PlayPause) state.eventSink(VoiceMessageEvent.PlayPause)
@@ -103,12 +105,16 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (!isTalkbackActive()) { if (!isTalkbackActive()) {
when (state.buttonType) { if (contentValidationValue.isValid()) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) when (state.buttonType) {
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton() VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
} else {
ProgressButton(displayImmediately = true)
} }
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.layout package io.element.android.features.messages.impl.timeline.components.layout
import android.text.Layout import android.text.Layout
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max import kotlin.math.max
import kotlin.math.min 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 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. * @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 @Composable
fun ContentAvoidingLayout( fun ContentAvoidingLayout(
overlay: @Composable () -> Unit, overlay: @Composable () -> Unit,
@@ -52,6 +55,15 @@ fun ContentAvoidingLayout(
) { ) {
val scope = remember { ContentAvoidingLayoutScopeInstance() } 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( Layout(
modifier = modifier, modifier = modifier,
content = { content = {
@@ -19,8 +19,6 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
aTimelineItemAudioContent("A sound.mp3"), aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"), aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"), aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
aTimelineItemAudioContent(caption = "A caption"),
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
) )
} }
@@ -15,6 +15,9 @@ import kotlin.time.Duration
@Immutable @Immutable
sealed interface TimelineItemEventContent { sealed interface TimelineItemEventContent {
val type: String val type: String
val isMedia: Boolean
get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent || this is TimelineItemAttachmentsContent
} }
interface TimelineItemEventMutableContent { interface TimelineItemEventMutableContent {
@@ -115,6 +118,13 @@ fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
else -> null else -> null
} }
fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (this) {
is TimelineItemEventContentWithAttachment -> formattedCaption
is TimelineItemGalleryContent -> formattedCaption
is TimelineItemAttachmentsContent -> formattedCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? { fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) { return when (this) {
is TimelineItemAudioContent -> duration is TimelineItemAudioContent -> duration
@@ -123,3 +133,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? {
else -> null else -> null
} }
} }
fun TimelineItemEventContentWithAttachment.blurHash(): String? {
return when (this) {
is TimelineItemImageContent -> blurhash
is TimelineItemVideoContent -> blurHash
is TimelineItemStickerContent -> blurhash
else -> null
}
}
@@ -135,6 +135,7 @@ fun anAttachmentItem(
fileSize: Long? = 1_000_000L, fileSize: Long? = 1_000_000L,
formattedFileSize: String = "1MB", formattedFileSize: String = "1MB",
mimeType: String? = null, mimeType: String? = null,
mediaSource: MediaSource = MediaSource(url = "", json = ""),
thumbnailSource: MediaSource? = null, thumbnailSource: MediaSource? = null,
hasThumbnail: Boolean = false, hasThumbnail: Boolean = false,
) = AttachmentItem( ) = AttachmentItem(
@@ -143,7 +144,7 @@ fun anAttachmentItem(
hasThumbnail -> "image/jpeg" hasThumbnail -> "image/jpeg"
else -> "application/$fileExtension" else -> "application/$fileExtension"
}, },
mediaSource = MediaSource(url = "", json = ""), mediaSource = mediaSource,
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null, thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
fileSize = fileSize, fileSize = fileSize,
formattedFileSize = formattedFileSize, formattedFileSize = formattedFileSize,
@@ -18,8 +18,6 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
aTimelineItemFileContent(), aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"), aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"), aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
aTimelineItemFileContent(caption = "A caption"),
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
) )
} }
@@ -20,6 +20,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
aTimelineItemVideoContent(), aTimelineItemVideoContent(),
aTimelineItemVideoContent(aspectRatio = 1.0f), aTimelineItemVideoContent(aspectRatio = 1.0f),
aTimelineItemVideoContent(aspectRatio = 1.5f), aTimelineItemVideoContent(aspectRatio = 1.5f),
aTimelineItemVideoContent(thumbnailMediaSource = MediaSource("https://example.com/thumbnail.jpg")),
aTimelineItemVideoContent(blurhash = null), aTimelineItemVideoContent(blurhash = null),
) )
} }
@@ -27,17 +28,20 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent( fun aTimelineItemVideoContent(
aspectRatio: Float = 0.5f, aspectRatio: Float = 0.5f,
blurhash: String? = A_BLUR_HASH, blurhash: String? = A_BLUR_HASH,
caption: String? = null,
mediaSource: MediaSource = MediaSource(""),
thumbnailMediaSource: MediaSource? = null,
) = TimelineItemVideoContent( ) = TimelineItemVideoContent(
filename = "Video.mp4", filename = "Video.mp4",
fileSize = 14 * 1024 * 1024L, fileSize = 14 * 1024 * 1024L,
caption = null, caption = caption,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
thumbnailSource = null, thumbnailSource = thumbnailMediaSource,
blurHash = blurhash, blurHash = blurhash,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
duration = 100.milliseconds, duration = 100.milliseconds,
mediaSource = MediaSource(""), mediaSource = mediaSource,
width = 150, width = 150,
height = 300, height = 300,
thumbnailWidth = 150, thumbnailWidth = 150,
@@ -9,7 +9,14 @@
package io.element.android.features.messages.impl.timeline.protection 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.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
sealed interface TimelineProtectionEvent { sealed interface TimelineProtectionEvent {
data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent
data class ValidateContent(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState,
) : TimelineProtectionEvent
} }
@@ -12,23 +12,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState 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.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewService 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.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@SingleIn(RoomScope::class)
@Inject @Inject
class TimelineProtectionPresenter( class TimelineProtectionPresenter(
private val mediaPreviewService: MediaPreviewService, private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom, private val room: BaseRoom,
private val contentScannerService: ContentScannerService,
) : Presenter<TimelineProtectionState> { ) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf()) private val allowedEvents = mutableStateSetOf<EventId>()
@Composable @Composable
override fun present(): TimelineProtectionState { override fun present(): TimelineProtectionState {
@@ -36,13 +41,14 @@ class TimelineProtectionPresenter(
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue } mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
}.collectAsState() }.collectAsState()
val roomInfo = room.roomInfoFlow.collectAsState() val roomInfo = room.roomInfoFlow.collectAsState()
val protectionState by remember { val protectionState by remember {
derivedStateOf { derivedStateOf {
val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule) val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule)
if (isPreviewEnabled) { if (isPreviewEnabled) {
ProtectionState.RenderAll ProtectionState.RenderAll
} else { } else {
ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet()) ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
} }
} }
} }
@@ -50,7 +56,10 @@ class TimelineProtectionPresenter(
fun handleEvent(event: TimelineProtectionEvent) { fun handleEvent(event: TimelineProtectionEvent) {
when (event) { when (event) {
is TimelineProtectionEvent.ShowContent -> { is TimelineProtectionEvent.ShowContent -> {
allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId) allowedEvents += setOfNotNull(event.eventId)
}
is TimelineProtectionEvent.ValidateContent -> {
contentScannerService.scan(event.eventId, event.mediaSources, event.validationState)
} }
} }
} }
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.util
import coil3.compose.AsyncImagePainter
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import okio.IOException
fun handleAsyncImageStateChange(
state: AsyncImagePainter.State,
onLoaded: () -> Unit,
updateContentValidationState: ((ContentValidationValue) -> Unit)?,
) {
if (state is AsyncImagePainter.State.Success) {
onLoaded()
}
val updatedContentValidationValue = when (state) {
is AsyncImagePainter.State.Empty -> ContentValidationValue.Unknown
is AsyncImagePainter.State.Loading -> ContentValidationValue.Loading
is AsyncImagePainter.State.Success -> ContentValidationValue.Valid
is AsyncImagePainter.State.Error -> {
when (val cause = state.result.throwable) {
is ClientException.ContentScanner if cause.reason.isDangerous() -> ContentValidationValue.Invalid
is IOException -> {
// Use `Unknown` instead of `Invalid` to allow retrying the download later
ContentValidationValue.Unknown
}
else -> ContentValidationValue.UnrecoverableError(cause)
}
}
}
updateContentValidationState?.invoke(updatedContentValidationValue)
}
@@ -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.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
@@ -315,6 +316,8 @@ class MessageComposerPresenterSlashCommandTest {
notificationConversationService = notificationConversationService, notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService, slashCommandService = slashCommandService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled
@@ -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.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions 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.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.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
@@ -1649,6 +1650,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
notificationConversationService = notificationConversationService, notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService, slashCommandService = slashCommandService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled
@@ -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.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.typing.aTypingNotificationState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -28,6 +30,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.EventId
@@ -1585,6 +1588,7 @@ class TimelinePresenterTest {
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1605,6 +1609,7 @@ class TimelinePresenterTest {
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead, markAsFullyRead = markAsFullyRead,
timelineProtectionPresenter = timelineProtectionPresenter,
) )
} }
} }
@@ -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.MessageShieldData
import io.element.android.features.messages.impl.timeline.components.aCriticalShield 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.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.event.aTimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
@@ -67,7 +67,7 @@ class TimelineViewTest : RobolectricTest() {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
@@ -79,7 +79,7 @@ class TimelineViewTest : RobolectricTest() {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
isLive = true, isLive = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
@@ -98,7 +98,7 @@ class TimelineViewTest : RobolectricTest() {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
isLive = false, isLive = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
@@ -133,7 +133,7 @@ class TimelineViewTest : RobolectricTest() {
timelineItems = persistentListOf<TimelineItem>( timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent( aTimelineItemEvent(
// Do not use a Text because EditorStyledText cannot be used in UI test. // Do not use a Text because EditorStyledText cannot be used in UI test.
content = aTimelineItemImageContent(), content = aTimelineItemTextContent(),
messageShield = MessageShield.UnverifiedIdentity(true), messageShield = MessageShield.UnverifiedIdentity(true),
), ),
), ),
@@ -155,7 +155,7 @@ class TimelineViewTest : RobolectricTest() {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
isLive = false, isLive = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
messageShield = aCriticalShield(), messageShield = aCriticalShield(),
@@ -9,19 +9,28 @@
package io.element.android.features.messages.impl.timeline.protection package io.element.android.features.messages.impl.timeline.protection
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewService 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.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.BaseRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -87,11 +96,49 @@ class TimelineProtectionPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - validate media scans the media source`() = runTest {
val url = "https://example.com/media"
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite), roomCoroutineScope = backgroundScope)
val contentScannerService = lambdaRecorder { _: EventId, _: List<MediaSource>, state: ContentValidationState ->
state.update(url, ContentValidationValue.Valid)
}
val presenter = createPresenter(
mediaPreviewService = mediaPreviewService,
room = room,
contentScannerService = contentScannerService,
)
presenter.test {
val validationState = DefaultContentValidationState(initial = mapOf(url to ContentValidationValue.Unknown))
assertThat(validationState.getCurrentOverallState().isValid()).isFalse()
val initialState = awaitItem()
initialState.eventSink(
TimelineProtectionEvent.ValidateContent(
eventId = AN_EVENT_ID,
mediaSources = listOf(MediaSource(url)),
validationState = validationState
)
)
runCurrent()
contentScannerService.assertions().isCalledOnce()
assertThat(validationState.getCurrentOverallState().isValid()).isTrue()
}
}
private fun createPresenter( private fun createPresenter(
room: BaseRoom = FakeBaseRoom(), room: BaseRoom = FakeBaseRoom(),
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: List<MediaSource>, _: ContentValidationState -> },
) = TimelineProtectionPresenter( ) = TimelineProtectionPresenter(
mediaPreviewService = mediaPreviewService, mediaPreviewService = mediaPreviewService,
room = room, room = room,
contentScannerService = contentScannerService,
) )
} }
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.util
import androidx.compose.ui.graphics.painter.Painter
import coil3.compose.AsyncImagePainter
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason.MCS_MEDIA_NOT_CLEAN
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk
import org.junit.Test
import java.io.FileNotFoundException
class AsyncImageStateHandlerTest {
@Test
fun `test Empty state sets the content validation state to Unknown`() {
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Empty,
onLoaded = {},
updateContentValidationState = updateContentValidationState,
)
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown))
}
@Test
fun `test Loading state sets the content validation state to Loading`() {
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Loading(null),
onLoaded = {},
updateContentValidationState = updateContentValidationState,
)
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Loading))
}
@Test
fun `test Success state sets the content validation state to Valid and triggers onLoaded callback`() {
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
val onLoaded = lambdaRecorder<Unit> {}
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Success(mockk<Painter>(), SuccessResult(mockk(), mockk())),
onLoaded = onLoaded,
updateContentValidationState = updateContentValidationState,
)
onLoaded.assertions().isCalledOnce()
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Valid))
}
@Test
fun `test Failure state sets the content validation state to Invalid if the error matches`() {
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
val onLoaded = lambdaRecorder<Unit> {}
val mediaNotCleanError = ClientException.ContentScanner("dangerous", MCS_MEDIA_NOT_CLEAN)
val forbiddenMimeTypeError = ClientException.ContentScanner("dangerous", ContentScannerErrorReason.MCS_MIME_TYPE_FORBIDDEN)
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), mediaNotCleanError)),
onLoaded = onLoaded,
updateContentValidationState = updateContentValidationState,
)
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), forbiddenMimeTypeError)),
onLoaded = onLoaded,
updateContentValidationState = updateContentValidationState,
)
onLoaded.assertions().isNeverCalled()
updateContentValidationState.assertions()
.isCalledExactly(2)
.withSequence(
listOf(value(ContentValidationValue.Invalid)),
listOf(value(ContentValidationValue.Invalid))
)
}
@Test
fun `test Failure state sets the content validation state to Uknown if the error does not match a content scanner one`() {
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
val onLoaded = lambdaRecorder<Unit> {}
val error = FileNotFoundException("File not found")
handleAsyncImageStateChange(
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), error)),
onLoaded = onLoaded,
updateContentValidationState = updateContentValidationState,
)
onLoaded.assertions().isNeverCalled()
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown))
}
}
@@ -29,21 +29,32 @@ fun EqualWidthColumn(
) { ) {
SubcomposeLayout(modifier = modifier) { constraints -> SubcomposeLayout(modifier = modifier) { constraints ->
val measurables = subcompose(0, content).map { it.measure(constraints) } val measurables = subcompose(0, content).map { it.measure(constraints) }
val maxWidth = measurables.maxOf { it.width } if (measurables.isEmpty()) {
val newConstraints = constraints.copy(minWidth = maxWidth) // No children, so just return a layout with 0 width and height
val newMeasurables = if (measurables.all { it.width == maxWidth }) { layout(0, 0) {}
// Skip re-measuring if all children have the same width } else if (measurables.size == 1) {
measurables // No need to re-measure if there's only one child
val measurable = measurables.first()
layout(measurable.width, measurable.height) {
measurable.placeRelative(0, 0)
}
} else { } else {
// Re-measure with the largest width as the minWidth to have all children constrained to the same width val maxWidth = measurables.maxOf { it.width }
subcompose(1, content).map { it.measure(newConstraints) } val newConstraints = constraints.copy(minWidth = maxWidth)
} val newMeasurables = if (measurables.all { it.width == maxWidth }) {
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() // Skip re-measuring if all children have the same width
layout(maxWidth, totalHeight) { measurables
var yPosition = 0 } else {
newMeasurables.forEach { measurable -> // Re-measure with the largest width as the minWidth to have all children constrained to the same width
measurable.placeRelative(0, yPosition) subcompose(1, content).map { it.measure(newConstraints) }
yPosition += measurable.height + spacing.roundToPx() }
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
layout(maxWidth, totalHeight) {
var yPosition = 0
newMeasurables.forEach { measurable ->
measurable.placeRelative(0, yPosition)
yPosition += measurable.height + spacing.roundToPx()
}
} }
} }
} }
@@ -124,3 +124,34 @@ data class CallNotifyContent(
) : EventContent ) : EventContent
data object UnknownContent : EventContent data object UnknownContent : EventContent
fun EventContent.isMediaContent(): Boolean {
return when (this) {
is MessageContent -> type is MessageTypeWithAttachment || type is GalleryMessageType
is StickerContent -> true
else -> false
}
}
fun EventContent.mediaSources(): List<MediaSource> {
return when (this) {
is MessageContent -> mediaSources()
is StickerContent -> listOfNotNull(source, info.thumbnailSource)
else -> emptyList()
}
}
fun MessageContent.mediaSources(): List<MediaSource> {
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()
}
}
@@ -115,6 +115,14 @@ sealed interface GalleryItemType {
data class Video(val content: VideoMessageType) : GalleryItemType data class Video(val content: VideoMessageType) : GalleryItemType
data class File(val content: FileMessageType) : GalleryItemType data class File(val content: FileMessageType) : GalleryItemType
data class Other(val itemType: String, val body: String) : GalleryItemType data class Other(val itemType: String, val body: String) : GalleryItemType
fun mediaSources(): List<MediaSource> = 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( data class OtherMessageType(
@@ -40,7 +40,7 @@ internal class CoilMediaFetcher(
onSuccess = { it }, onSuccess = { it },
onFailure = { error -> onFailure = { error ->
if (error.isNetworkError()) { if (error.isNetworkError()) {
null throw error
} else { } else {
fetchContent(mediaSource) fetchContent(mediaSource)
} }
@@ -73,7 +73,7 @@ internal class CoilMediaFetcher(
.onFailure { .onFailure {
Timber.e(it) Timber.e(it)
} }
.getOrNull() .getOrThrow()
} }
private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? { private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? {
@@ -83,7 +83,7 @@ internal class CoilMediaFetcher(
byteArray.asSourceResult() byteArray.asSourceResult()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
}.getOrNull() }.getOrThrow()
} }
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> { private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> {
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@Composable
internal fun ContentErrorView(
title: String,
message: String,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
val updatedOnTextLayout by rememberUpdatedState(onTextLayout)
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgCriticalSubtle)
.padding(contentPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Error(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary
)
Column(Modifier.weight(1f)) {
Text(
text = title,
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
val textMeasurer = rememberTextMeasurer()
val textContent = remember(message) {
movableContentOf(
@Composable {
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
)
}
// BoxWithConstraints is needed to be able to calculate the text layout in the current constraints, so we can pass it to the onTextLayout callback.
// However, this can't be used inside a SubComposeLayout (like the one in the text composer), so we only use it when the onTextLayout callback
// is provided.
if (updatedOnTextLayout != null) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
LaunchedEffect(message) {
updatedOnTextLayout?.invoke(textMeasurer.measure(message, overflow = TextOverflow.Visible, constraints = constraints))
}
textContent()
}
} else {
textContent()
}
}
}
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
/**
* Wrapper for the content validation state of an event.
*
* It can be used to check the overall validation state of the event, as well as the validation state of each media item in it.
*/
@Immutable
interface ContentValidationState {
/**
* A flow that emits the overall validation state of the event, which is an aggregation of the different media values.
*/
val overallStateFlow: Flow<ContentValidationValue>
/**
* Returns a flow that emits the validation state of a specific media item, identified by its URL.
*/
fun getMediaStateFlow(url: String): Flow<ContentValidationValue>
/**
* Returns the current overall validation state of the event.
*/
fun getCurrentOverallState(): ContentValidationValue
/**
* Returns the current validation state of a specific media item, identified by its URL.
*/
fun getCurrentMediaState(url: String): ContentValidationValue
/**
* Updates the validation state of a specific media item, identified by its URL, with a new [ContentValidationValue].
*/
fun update(url: String, newValue: ContentValidationValue)
}
/**
* A no-op implementation of [ContentValidationState] that always returns a fixed validation state.
* By default, this is [ContentValidationValue.Valid].
*/
@Immutable
class NoopContentValidationState(
private val initial: ContentValidationValue = ContentValidationValue.Valid,
) : ContentValidationState {
override val overallStateFlow: Flow<ContentValidationValue> = MutableStateFlow(initial)
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> = MutableStateFlow(initial)
override fun getCurrentOverallState(): ContentValidationValue = initial
override fun getCurrentMediaState(url: String): ContentValidationValue = initial
override fun update(url: String, newValue: ContentValidationValue) {}
}
/**
* A default implementation of [ContentValidationState] that tracks the validation state of media items in a mutable map.
* It calculates the overall validation state based on the individual media states.
*/
@Immutable
class DefaultContentValidationState(
private val states: MutableStateFlow<Map<String, ContentValidationValue>>,
) : ContentValidationState {
@OptIn(ExperimentalCoroutinesApi::class)
override val overallStateFlow: Flow<ContentValidationValue> = states
.mapLatest { states -> calculateOverallState(states) }
.distinctUntilChanged()
constructor() : this(states = MutableStateFlow(emptyMap()))
constructor(initial: Map<String, ContentValidationValue>) : this(states = MutableStateFlow(initial))
@OptIn(ExperimentalCoroutinesApi::class)
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> {
return states.map { states ->
states[url] ?: ContentValidationValue.Unknown
}.distinctUntilChanged()
}
override fun getCurrentOverallState(): ContentValidationValue {
return calculateOverallState(states.value)
}
override fun getCurrentMediaState(url: String): ContentValidationValue {
return states.value[url] ?: ContentValidationValue.Unknown
}
override fun update(url: String, newValue: ContentValidationValue) {
states.update { current ->
if (current[url]?.isValidated() == true && newValue.isLoading()) {
// If the current state is already validated and the new value is loading, we keep the current validated state
current
} else {
// Otherwise, we update the state for the given URL with the new value
current + (url to newValue)
}
}
}
}
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
return when {
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid
else -> ContentValidationValue.Unknown
}
}
/**
* Represents the validation state of a media item or an event.
*/
@Immutable
sealed interface ContentValidationValue {
/** Indicates that the content is valid. */
data object Valid : ContentValidationValue
/** Indicates that the content is invalid/unsafe. */
data object Invalid : ContentValidationValue
/** Indicates that the content is currently being validated. */
data object Loading : ContentValidationValue
/** Indicates that an unrecoverable error occurred during validation, and it should not be retried. */
data class UnrecoverableError(val error: Throwable) : ContentValidationValue
/** Indicates that the validation state is unknown and should be checked. Recoverable errors are also represented with this. */
data object Unknown : ContentValidationValue
/**
* Returns true if the validation state is either [Valid], [Invalid], or [UnrecoverableError], meaning that the validation process has completed
* and a definitive result is available.
*/
fun isValidated(): Boolean = when (this) {
is Valid -> true
is Invalid -> true
is Loading -> false
is UnrecoverableError -> true
is Unknown -> false
}
/** Returns true if the validation state is [Valid]. */
fun isValid(): Boolean = this is Valid
/** Returns true if the validation state is [Invalid]. */
fun isInvalid(): Boolean = this is Invalid
/** Returns true if the validation state is [Loading]. */
fun isLoading(): Boolean = this is Loading
/** Returns true if the validation state is [UnrecoverableError]. */
fun hasUnrecoverableError(): Boolean = this is UnrecoverableError
/** Returns true if the validation state is either [Invalid] or [UnrecoverableError]. */
fun hasError(): Boolean = this is Invalid || this is UnrecoverableError
}
/**
* Collects the overall validation state of the [ContentValidationState] as a [State] in a Composable context.
*/
@Composable
fun ContentValidationState.collectOverallState(): State<ContentValidationValue> {
return produceState(getCurrentOverallState()) {
overallStateFlow.collect { value = it }
}
}
/**
* Collects the validation state of a specific media item, identified by its URL, as a [State] in a Composable context.
*/
@Composable
fun ContentValidationState.collectMediaState(url: String?): State<ContentValidationValue> {
if (url == null) {
return remember { mutableStateOf(ContentValidationValue.Unknown) }
}
return produceState(getCurrentMediaState(url), key1 = url) {
getMediaStateFlow(url).collect { value = it }
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import io.element.android.libraries.matrix.api.core.EventId
/**
* A cache that holds the validation state of event contents, based on their [EventId].
*/
interface EventContentValidationCache {
/**
* Returns the [ContentValidationState] for the given [eventId]. If none exists, it creates a new one with [ContentValidationValue.Unknown].
*/
operator fun get(eventId: EventId): ContentValidationState
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import io.element.android.libraries.matrix.api.core.EventId
/**
* In-memory variant of [EventContentValidationCache] that holds the validation state of event contents, based on their [EventId].
*
* Note: this is intended to be used in tests.
*/
class InMemoryEventContentValidationCache(
initial: Map<EventId, ContentValidationState> = emptyMap(),
) : EventContentValidationCache {
private val cache = initial.toMutableMap()
override operator fun get(eventId: EventId): ContentValidationState {
return cache[eventId] ?: DefaultContentValidationState()
}
fun put(eventId: EventId, state: ContentValidationState) {
cache[eventId] = state
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view to display when the media content is invalid or dangerous.
*/
@Composable
fun InvalidContentView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
ContentErrorView(
title = stringResource(CommonStrings.content_scanner_unsafe_title),
message = stringResource(CommonStrings.content_scanner_unsafe_message),
modifier = modifier,
contentPadding = contentPadding,
onTextLayout = onTextLayout,
)
}
@PreviewsDayNight
@Composable
internal fun InvalidContentViewPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InvalidContentView()
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
private val noopValue = NoopContentValidationState()
/**
* A composition local that provides an [EventContentValidationCache] instance, so we can check if a given event content has been validated or not.
*/
val LocalEventContentValidationState = staticCompositionLocalOf<EventContentValidationCache> { NoopEventContentValidationCache() }
/**
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
*/
class NoopEventContentValidationCache : EventContentValidationCache {
override operator fun get(eventId: EventId): ContentValidationState = noopValue
}
/**
* A helper to remember the validation state of an event content, based on its [EventId] and [EventContent], if known.
*/
@Composable
fun rememberEventContentValidationState(eventId: EventId?, eventContent: EventContent?): ContentValidationState {
val needsValidation = remember(eventContent) {
when (eventContent) {
is MessageContent -> eventContent.isMediaContent()
is StickerContent -> true
// If the event content is not known or not a media content, we don't need to validate it.
else -> false
}
}
return rememberEventContentValidationState(eventId, needsValidation)
}
/**
* A helper to remember the validation state of an event content, based on its [EventId] and whether it needs validation or not.
*/
@Composable
fun rememberEventContentValidationState(eventId: EventId?, needsValidation: Boolean): ContentValidationState {
if (!needsValidation) {
return noopValue
}
return rememberEventContentValidationStateInternal(eventId)
}
@Composable
private fun rememberEventContentValidationStateInternal(eventId: EventId?): ContentValidationState {
val cache = LocalEventContentValidationState.current
return remember(eventId) {
if (eventId != null) {
cache[eventId]
} else {
noopValue
}
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view to display when the media content can't be fetched.
*/
@Composable
fun NotFoundContentView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
ContentErrorView(
title = stringResource(CommonStrings.content_scanner_not_found_title),
message = stringResource(CommonStrings.content_scanner_not_found),
modifier = modifier,
contentPadding = contentPadding,
onTextLayout = onTextLayout,
)
}
@PreviewsDayNight
@Composable
internal fun NotFoundContentViewPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NotFoundContentView()
}
}
@@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) {
is InReplyToDetails.Error -> eventId is InReplyToDetails.Error -> eventId
} }
fun InReplyToDetails.content() = when (this) {
is InReplyToDetails.Ready -> eventContent
is InReplyToDetails.Loading -> null
is InReplyToDetails.Error -> null
}
fun InReplyTo.map( fun InReplyTo.map(
permalinkParser: PermalinkParser, permalinkParser: PermalinkParser,
) = when (this) { ) = when (this) {
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -44,6 +45,9 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName 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.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -55,18 +59,22 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun InReplyToView( fun InReplyToView(
inReplyTo: InReplyToDetails, inReplyTo: InReplyToDetails,
hideImage: Boolean, hideImage: Boolean,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxLines: Int = 2, maxLines: Int = 2,
) { ) {
when (inReplyTo) { when (inReplyTo) {
is InReplyToDetails.Ready -> { is InReplyToDetails.Ready -> when (contentValidationValue) {
ReplyToReadyContent( ContentValidationValue.Valid -> ReplyToReadyContent(
senderId = inReplyTo.senderId, senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile, senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(hideImage), metadata = inReplyTo.metadata(hideImage),
maxLines = maxLines, maxLines = maxLines,
modifier = modifier, modifier = modifier,
) )
ContentValidationValue.Invalid -> ReplyToInvalidContent()
is ContentValidationValue.UnrecoverableError -> ReplyToNotFoundContent()
else -> ReplyToLoadingContent(modifier = modifier)
} }
is InReplyToDetails.Error -> is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
@@ -86,7 +94,7 @@ private fun ReplyToReadyContent(
val paddings = if (metadata is InReplyToMetadata.Thumbnail) { val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(end = 8.dp) PaddingValues(end = 8.dp)
} else { } else {
PaddingValues(start = 8.dp, end = 8.dp) PaddingValues(horizontal = 8.dp)
} }
Row( Row(
modifier modifier
@@ -213,11 +221,50 @@ private fun ReplyToContentText(
} }
} }
@Composable
private fun ReplyToInvalidContent(
modifier: Modifier = Modifier
) {
InvalidContentView(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp),
onTextLayout = null,
)
}
@Composable
private fun ReplyToNotFoundContent(
modifier: Modifier = Modifier
) {
NotFoundContentView(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp),
onTextLayout = null,
)
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview {
InReplyToView( InReplyToView(
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
hideImage = false, hideImage = false,
contentValidationValue = ContentValidationValue.Valid,
) )
} }
@PreviewsDayNight
@Composable
internal fun ReplyToInvalidContentPreview() {
ElementPreview {
ReplyToInvalidContent(modifier = Modifier.padding(10.dp))
}
}
@PreviewsDayNight
@Composable
internal fun ReplyToNotFoundContentPreview() {
ElementPreview {
ReplyToNotFoundContent(modifier = Modifier.padding(10.dp))
}
}
@@ -18,6 +18,7 @@ data class GalleryItemData(
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val type: Type, val type: Type,
val blurHash: String?,
) : Parcelable { ) : Parcelable {
enum class Type { enum class Type {
Image, Image,
@@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Params ) : Params
data class EventGallery( data class EventGallery(
@@ -55,6 +56,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
val avatarInfo: AvatarInfo, val avatarInfo: AvatarInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Params ) : Params
} }
@@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
), ),
mediaSource = MediaSource(url = avatarUrl), mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null, thumbnailSource = null,
// Maybe add avatar blurhash if available?
blurHash = null,
) )
} }
@@ -116,6 +116,7 @@ class EventItemFactory(
), ),
mediaSource = c.source, mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource, thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
) )
} }
is GalleryItemType.Video -> { is GalleryItemType.Video -> {
@@ -136,6 +137,7 @@ class EventItemFactory(
), ),
mediaSource = c.source, mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource, thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
) )
} }
is GalleryItemType.Audio -> { is GalleryItemType.Audio -> {
@@ -242,6 +244,7 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
)) ))
is StickerMessageType -> listOf(MediaItem.Image( is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@@ -264,6 +267,7 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
)) ))
is VideoMessageType -> listOf(MediaItem.Video( is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@@ -286,6 +290,7 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
)) ))
is VoiceMessageType -> listOf(MediaItem.Voice( is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@@ -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.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode 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.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.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaSource import io.element.android.libraries.mediaviewer.impl.model.mediaSource
@@ -68,6 +69,7 @@ class MediaGalleryFlowNode(
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val blurHash: String?,
) : NavTarget ) : NavTarget
} }
@@ -104,6 +106,7 @@ class MediaGalleryFlowNode(
mediaInfo = item.mediaInfo(), mediaInfo = item.mediaInfo(),
mediaSource = item.mediaSource(), mediaSource = item.mediaSource(),
thumbnailSource = item.thumbnailSource(), thumbnailSource = item.thumbnailSource(),
blurHash = item.blurHash(),
) )
) )
} }
@@ -134,6 +137,7 @@ class MediaGalleryFlowNode(
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
), ),
callback = callback, callback = callback,
) )
@@ -34,6 +34,7 @@ import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,6 +52,7 @@ fun VideoItemView(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.blurHashBackground(blurHash = video.blurHash)
.aspectRatio(1f) .aspectRatio(1f)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
@@ -35,6 +35,7 @@ sealed interface MediaItem {
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Event { ) : Event {
val thumbnailMediaRequestData: MediaRequestData val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -46,6 +47,7 @@ sealed interface MediaItem {
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Event { ) : Event {
val thumbnailMediaRequestData: MediaRequestData val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
is MediaItem.Voice -> null is MediaItem.Voice -> null
} }
} }
fun MediaItem.Event.blurHash(): String? {
return when (this) {
is MediaItem.Image -> blurHash
is MediaItem.Video -> blurHash
else -> null
}
}
@@ -34,6 +34,7 @@ fun aMediaItemImage(
), ),
mediaSource = MediaSource(mediaSourceUrl), mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
} }
@@ -50,6 +51,7 @@ fun aMediaItemVideo(
), ),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
} }
@@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource(
mediaInfo = itemMediaInfo, mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource, mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource, thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
) )
GalleryItemData.Type.Audio -> MediaItem.Audio( GalleryItemData.Type.Audio -> MediaItem.Audio(
id = id, id = id,
@@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource(
mediaInfo = itemMediaInfo, mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource, mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource, thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
) )
} }
mixedItems.add(mediaItem) mixedItems.add(mediaItem)
@@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource(
), ),
mediaSource = params.mediaSource, mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource, thumbnailSource = params.thumbnailSource,
blurHash = params.blurHash,
) )
), ),
fileItems = persistentListOf(), fileItems = persistentListOf(),
@@ -79,6 +80,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo, mediaInfo = mediaInfo,
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,
blurHash = blurHash,
) )
} }
mediaInfo.mimeType.isMimeTypeVideo() -> { mediaInfo.mimeType.isMimeTypeVideo() -> {
@@ -88,6 +90,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo, mediaInfo = mediaInfo,
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,
blurHash = blurHash,
) )
} }
mediaInfo.mimeType.isMimeTypeAudio() -> { mediaInfo.mimeType.isMimeTypeAudio() -> {
@@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest {
avatarInfo = AvatarInfo(filename = "avatar.png"), avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = MediaSource(url = "avatarUrl"), mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
) )
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
@@ -226,6 +226,7 @@ class EventItemFactoryTest {
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
) )
} }
@@ -326,6 +327,7 @@ class EventItemFactoryTest {
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
) )
} }
@@ -437,6 +439,7 @@ class EventItemFactoryTest {
), ),
mediaSource = MediaSource("image_url"), mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
) )
) )
} }
@@ -498,6 +501,7 @@ class EventItemFactoryTest {
), ),
mediaSource = MediaSource("video_url"), mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
) )
) )
} }
@@ -744,6 +748,7 @@ class EventItemFactoryTest {
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
) )
} }
@@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest {
), ),
mediaSource = MediaSource("url"), mediaSource = MediaSource("url"),
thumbnailSource = MediaSource("url_thumbnail"), thumbnailSource = MediaSource("url_thumbnail"),
blurHash = A_BLUR_HASH,
) )
), ),
fileItems = persistentListOf() fileItems = persistentListOf()
@@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"), mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Image, type = GalleryItemData.Type.Image,
blurHash = null,
) )
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg), mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
mediaSource = MediaSource("image_url"), mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
) )
) )
} }
@@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("video_url"), mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Video, type = GalleryItemData.Type.Video,
blurHash = null,
) )
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4), mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
mediaSource = MediaSource("video_url"), mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"), thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
) )
) )
} }
@@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("audio_url"), mediaSource = MediaSource("audio_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.Audio, type = GalleryItemData.Type.Audio,
blurHash = null,
) )
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("file_url"), mediaSource = MediaSource("file_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.File, type = GalleryItemData.Type.File,
blurHash = null,
) )
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"), mediaSource = MediaSource("image_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.Image, type = GalleryItemData.Type.Image,
blurHash = null,
) )
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"), mediaSource = MediaSource("image_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.Image, type = GalleryItemData.Type.Image,
blurHash = null,
), ),
GalleryItemData( GalleryItemData(
filename = "document.pdf", filename = "document.pdf",
@@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("file_url"), mediaSource = MediaSource("file_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.File, type = GalleryItemData.Type.File,
blurHash = null,
), ),
GalleryItemData( GalleryItemData(
filename = "video.mp4", filename = "video.mp4",
@@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("video_url"), mediaSource = MediaSource("video_url"),
thumbnailSource = null, thumbnailSource = null,
type = GalleryItemData.Type.Video, type = GalleryItemData.Type.Video,
blurHash = null,
), ),
), ),
galleryInfo = aGalleryInfo(), galleryInfo = aGalleryInfo(),
@@ -114,6 +114,7 @@ class MediaViewerPresenterTest {
avatarInfo = AvatarInfo(filename = "avatar.png"), avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
), ),
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
@@ -983,4 +984,5 @@ internal fun createMediaViewerEntryPointParams(
mediaInfo = TESTED_MEDIA_INFO, mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
thumbnailSource = null, thumbnailSource = null,
blurHash = null,
) )
@@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = params.mediaInfo, mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource, mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource, thumbnailSource = params.thumbnailSource,
blurHash = null,
) )
} }
) )
@@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = params.mediaInfo, mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource, mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource, thumbnailSource = params.thumbnailSource,
blurHash = null,
) )
} }
) )
@@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = mediaInfo, mediaInfo = mediaInfo,
mediaSource = aMediaSource(url = "aUrl"), mediaSource = aMediaSource(url = "aUrl"),
thumbnailSource = aMediaSource(url = "aThumbnailUrl"), thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
blurHash = null,
) )
} }
@@ -13,6 +13,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -35,8 +37,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView 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.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -134,28 +139,44 @@ private fun ReplyToModeView(
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( val shape = RoundedCornerShape(6.dp)
val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent)
val currentValidationState by contentValidationState.collectOverallState()
val contentHasErrors = currentValidationState.hasError()
Box(
modifier modifier
.clip(RoundedCornerShape(6.dp)) .clip(shape)
.background(ElementTheme.colors.bgCanvasDefault) .background(
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) color = if (contentHasErrors) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault,
shape = shape,
)
.border(
width = 1.dp,
color = if (contentHasErrors) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary,
shape = shape,
)
.padding(4.dp) .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 // 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 currentDensity = LocalDensity.current
val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE
val maxReplyContentLines = if (hasLowResolution) 1 else 2 val maxReplyContentLines = if (hasLowResolution) 1 else 2
InReplyToView( InReplyToView(
inReplyTo = replyToDetails, inReplyTo = replyToDetails,
hideImage = hideImage, hideImage = hideImage,
contentValidationValue = currentValidationState,
maxLines = maxReplyContentLines, maxLines = maxReplyContentLines,
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
) )
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close), contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.colors.iconSecondary, tint = if (contentHasErrors) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp) .padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp)
.size(16.dp) .size(16.dp)
.clickable( .clickable(
@@ -34,6 +34,7 @@ class KonsistArchitectureTest {
.classes() .classes()
.withNameEndingWith("State") .withNameEndingWith("State")
.withoutName( .withoutName(
"NoopContentValidationState",
"CameraPositionState", "CameraPositionState",
"CustomSheetState", "CustomSheetState",
) )
@@ -145,7 +145,8 @@ class KonsistPreviewTest {
"TextComposerVoiceNotEncryptedPreview", "TextComposerVoiceNotEncryptedPreview",
"TextComposerVoicePreview", "TextComposerVoicePreview",
"TextFieldDialogWithErrorPreview", "TextFieldDialogWithErrorPreview",
"TimelineImageWithCaptionRowPreview", "TimelineItemAttachmentsViewScanningContentFailedPreview",
"TimelineItemAudioViewScanningContentPreview",
"TimelineItemEventRowForDirectRoomPreview", "TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview", "TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview", "TimelineItemEventRowTimestampPreview",
@@ -155,13 +156,21 @@ class KonsistPreviewTest {
"TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithRRPreview",
"TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithReplyPreview",
"TimelineItemEventRowWithThreadSummaryPreview", "TimelineItemEventRowWithThreadSummaryPreview",
"TimelineItemFileViewScanningContentPreview",
"TimelineItemGalleryViewScanningContentFailedPreview",
"TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentCollapsePreview",
"TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemGroupedEventsRowContentExpandedPreview",
"TimelineItemImageViewHideMediaContentPreview", "TimelineItemImageViewHideMediaContentPreview",
"TimelineItemImageViewScanningContentPreview",
"TimelineItemRedactedMessagesGroupPreview", "TimelineItemRedactedMessagesGroupPreview",
"TimelineItemScanningContentFailedPreview",
"TimelineItemScanningContentWithInvalidRepliesPreview",
"TimelineItemScanningContentWithRepliesFailedPreview",
"TimelineItemStickerViewScanningContentPreview",
"TimelineItemVideoViewHideMediaContentPreview", "TimelineItemVideoViewHideMediaContentPreview",
"TimelineItemVideoViewScanningContentPreview",
"TimelineItemVoiceViewScanningContentPreview",
"TimelineItemVoiceViewUnifiedPreview", "TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview", "TimelineViewMessageShieldPreview",
"TimelineViewWithReadMarkerBothIndicatorsPreview", "TimelineViewWithReadMarkerBothIndicatorsPreview",
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview", "TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
@@ -26,6 +26,8 @@ import com.android.resources.Density.DEFAULT_DENSITY
import com.android.resources.NightMode import com.android.resources.NightMode
import com.android.resources.ScreenOrientation import com.android.resources.ScreenOrientation
import io.element.android.compound.theme.ElementTheme 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.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
import java.util.Locale import java.util.Locale
@@ -55,6 +57,7 @@ object ScreenshotTest {
setLocales(LocaleList(locale)) setLocales(LocaleList(locale))
uiMode = preview.previewInfo.uiMode uiMode = preview.previewInfo.uiMode
}, },
LocalEventContentValidationState provides NoopEventContentValidationCache(),
) { ) {
ElementTheme { ElementTheme {
Box( Box(
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
size 612494

Some files were not shown because too many files have changed in this diff Show More