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:
committed by
GitHub
parent
56cf00560e
commit
0d03472c60
+23
@@ -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.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
+56
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ dependencies {
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.features.contentscanner.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
||||
+6
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.blurHash
|
||||
import io.element.android.features.messages.impl.timeline.model.event.duration
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
@@ -155,6 +156,7 @@ class MessagesFlowNode(
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val canUseOverlay: Boolean,
|
||||
val blurHash: String?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
@@ -377,6 +379,7 @@ class MessagesFlowNode(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
blurHash = navTarget.blurHash,
|
||||
)
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
@@ -828,6 +831,7 @@ class MessagesFlowNode(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
type = galleryItem.type.toMediaViewerType(),
|
||||
blurHash = galleryItem.blurhash,
|
||||
)
|
||||
}.reversed()
|
||||
NavTarget.GalleryViewer(
|
||||
@@ -861,6 +865,7 @@ class MessagesFlowNode(
|
||||
mediaSource = attachment.mediaSource,
|
||||
thumbnailSource = attachment.thumbnailSource,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
)
|
||||
}.reversed()
|
||||
NavTarget.GalleryViewer(
|
||||
@@ -920,6 +925,7 @@ class MessagesFlowNode(
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
canUseOverlay = canUseOverlay,
|
||||
blurHash = content.blurHash(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -66,6 +66,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -97,6 +99,7 @@ class MessagesNode(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
private val eventContentValidationCache: EventContentValidationCache,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val focusedEventId: EventId?,
|
||||
@@ -261,6 +264,7 @@ class MessagesNode(
|
||||
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
LocalEventContentValidationState provides eventContentValidationCache,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
|
||||
|
||||
+7
@@ -123,6 +123,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
@@ -148,6 +150,8 @@ fun MessagesView(
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
val eventContentValidationState = LocalEventContentValidationState.current
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
|
||||
}
|
||||
@@ -170,6 +174,9 @@ fun MessagesView(
|
||||
|
||||
fun onContentClick(event: TimelineItem.Event) {
|
||||
Timber.v("onMessageClick= ${event.id}")
|
||||
val eventId = event.eventId ?: return
|
||||
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
|
||||
|
||||
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
|
||||
if (hideKeyboard) {
|
||||
localView.hideKeyboard()
|
||||
|
||||
+12
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
@@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.use
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.content
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
private val slashCommandService: SlashCommandService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -848,6 +855,11 @@ class MessageComposerPresenter(
|
||||
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
|
||||
}
|
||||
else -> {
|
||||
if (currentComposerMode is MessageComposerMode.Reply) {
|
||||
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
|
||||
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
|
||||
contentScannerService.scan(currentComposerMode.replyToDetails.eventId(), mediaSources, contentValidationState)
|
||||
}
|
||||
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
|
||||
if (currentComposerMode.isEditing) {
|
||||
setText("", markdownTextEditorState, richTextEditorState)
|
||||
|
||||
+2
-3
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.poll.api.pollcontent.PollTitleView
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
@@ -290,9 +289,9 @@ private fun TimelineItemEventContentViewWrapper(
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
|
||||
+8
@@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import kotlin.time.Duration
|
||||
|
||||
sealed interface TimelineEvent {
|
||||
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data object StopLiveLocationShare : TimelineItemEvent
|
||||
|
||||
data class ValidateMedia(
|
||||
val eventId: EventId,
|
||||
val mediaSources: List<MediaSource>,
|
||||
val validationState: ContentValidationState
|
||||
) : TimelineItemEvent
|
||||
}
|
||||
|
||||
+10
-1
@@ -35,6 +35,8 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -64,6 +66,7 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -98,6 +101,7 @@ class TimelinePresenter(
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
|
||||
@@ -157,6 +161,8 @@ class TimelinePresenter(
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
is TimelineEvent.LoadMore -> {
|
||||
@@ -252,6 +258,9 @@ class TimelinePresenter(
|
||||
focusedEventId = event.focusedEvent,
|
||||
)
|
||||
}
|
||||
is TimelineEvent.ValidateMedia -> {
|
||||
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSources, event.validationState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +268,7 @@ class TimelinePresenter(
|
||||
timelineItemsFactory.timelineItems
|
||||
.onEach { newTimelineItems ->
|
||||
timelineItemIndexer.process(newTimelineItems)
|
||||
timelineItems = newTimelineItems
|
||||
timelineItems = newTimelineItems.toImmutableList()
|
||||
|
||||
analyticsService.run {
|
||||
finishLongRunningTransaction(DisplayFirstTimelineItems)
|
||||
|
||||
+2
-1
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
enum class ContentPadding {
|
||||
Textual,
|
||||
Media,
|
||||
CaptionedMedia
|
||||
CaptionedMedia,
|
||||
InvalidContent,
|
||||
}
|
||||
|
||||
+12
-1
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -62,6 +66,8 @@ fun MessageEventBubble(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
customBackgroundColor: Color? = null,
|
||||
borderColor: Color? = null,
|
||||
content: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val clickableModifier = if (isTalkbackActive()) {
|
||||
@@ -79,10 +85,12 @@ fun MessageEventBubble(
|
||||
|
||||
val cutTopStart = state.cutTopStart
|
||||
// Ignore state.isHighlighted for now, we need a design decision on it.
|
||||
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
|
||||
val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
|
||||
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
|
||||
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
|
||||
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
|
||||
|
||||
val updatedBorderColor by rememberUpdatedState(borderColor)
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.drawWithCache {
|
||||
@@ -103,6 +111,9 @@ fun MessageEventBubble(
|
||||
// Then draw the content on top of it
|
||||
drawContent()
|
||||
|
||||
// Draw border color, if any
|
||||
updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) }
|
||||
|
||||
// And then clip the top start corner if needed to make room for the avatar
|
||||
if (cutTopStart) {
|
||||
drawCircle(
|
||||
|
||||
+320
@@ -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)
|
||||
)
|
||||
+85
-28
@@ -33,9 +33,12 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -88,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -122,9 +124,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.content
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
@@ -176,12 +182,12 @@ fun TimelineItemEventRow(
|
||||
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
||||
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
@@ -468,6 +474,23 @@ private fun TimelineItemEventRowContent(
|
||||
)
|
||||
}
|
||||
|
||||
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
|
||||
.collectOverallState()
|
||||
val needsInvalidContentCustomisations =
|
||||
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
|
||||
event.content !is TimelineItemGalleryContent &&
|
||||
event.content !is TimelineItemAttachmentsContent &&
|
||||
currentContentValidationState.hasError() &&
|
||||
event.content.isMedia
|
||||
|
||||
// If the event has a dangerous media content we need to set custom message bubble background and border colors
|
||||
val themeColors = ElementTheme.colors
|
||||
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
|
||||
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
|
||||
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
|
||||
background to border
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
@@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
customBackgroundColor = dangerousContentBubbleColor,
|
||||
borderColor = borderColor,
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
@@ -747,6 +772,7 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
ContentPadding.CaptionedMedia ->
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
|
||||
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
|
||||
}
|
||||
|
||||
val threadDecoration = @Composable {
|
||||
@@ -770,10 +796,13 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
|
||||
.collectOverallState()
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val inReplyToModifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clip(shape)
|
||||
|
||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||
// Use z-index to make the replied to text being read after the message
|
||||
@@ -782,14 +811,25 @@ private fun MessageEventBubbleContent(
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
}
|
||||
|
||||
val contentHasError = currentContentValidationState.hasError()
|
||||
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
||||
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||
Box(
|
||||
modifier = talkbackCompatModifier
|
||||
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
|
||||
.border(1.dp, borderColor, shape)
|
||||
.background(backgroundColor, shape)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
|
||||
val updatedEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(inReplyTo) {
|
||||
val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect
|
||||
updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState))
|
||||
}
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
contentValidationValue = currentContentValidationState,
|
||||
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
||||
)
|
||||
}
|
||||
@@ -809,30 +849,47 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
}
|
||||
|
||||
val timestampPosition = when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemAttachmentsContent -> TimestampPosition.Below
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> {
|
||||
val content = content.ensureActiveLiveLocation()
|
||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||
content.mode.isActive &&
|
||||
content.mode.isOwnUser
|
||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
|
||||
val needsInvalidContentLayout =
|
||||
// Gallery events should not apply custom paddings or layout dispositions
|
||||
event.content !is TimelineItemGalleryContent &&
|
||||
event.content !is TimelineItemAttachmentsContent &&
|
||||
contentValidationState.hasError()
|
||||
|
||||
val timestampPosition = if (needsInvalidContentLayout) {
|
||||
// The invalid content view will be displayed in all these cases, independent of the event content
|
||||
TimestampPosition.Aligned
|
||||
} else {
|
||||
when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemAttachmentsContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> {
|
||||
val content = content.ensureActiveLiveLocation()
|
||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||
content.mode.isActive &&
|
||||
content.mode.isOwnUser
|
||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
}
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
val paddingBehaviour = when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> ContentPadding.Media
|
||||
else -> ContentPadding.Textual
|
||||
|
||||
val paddingBehaviour = if (needsInvalidContentLayout) {
|
||||
ContentPadding.InvalidContent
|
||||
} else {
|
||||
when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> ContentPadding.Media
|
||||
else -> ContentPadding.Textual
|
||||
}
|
||||
}
|
||||
CommonLayout(
|
||||
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
|
||||
|
||||
+4
-5
@@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -62,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
@@ -134,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
|
||||
+3
-3
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.colors.gradientSubtleColors
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
@@ -77,9 +76,9 @@ internal fun TimelineItemRow(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onContentClick = { onContentClick(event) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
|
||||
onLongClick = { onLongClick(event) },
|
||||
@@ -119,6 +118,7 @@ internal fun TimelineItemRow(
|
||||
onClick = { onContentClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-2
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -43,6 +45,7 @@ fun TimelineItemStateEventRow(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -67,11 +70,11 @@ fun TimelineItemStateEventRow(
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
hideMediaContent = false,
|
||||
onShowContentClick = {},
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
eventSink = eventSink,
|
||||
onContentClick = null,
|
||||
onGalleryItemClick = {},
|
||||
@@ -107,6 +110,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onReadReceiptsClick = {},
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
||||
+22
-42
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,8 +24,10 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
|
||||
/**
|
||||
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
|
||||
@@ -38,9 +39,9 @@ fun TimelineItemAttachmentView(
|
||||
iconContentDescription: String?,
|
||||
filename: String,
|
||||
fileExtensionAndSize: String,
|
||||
caption: String?,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@@ -50,16 +51,9 @@ fun TimelineItemAttachmentView(
|
||||
iconContentDescription = iconContentDescription,
|
||||
filename = filename,
|
||||
fileExtensionAndSize = fileExtensionAndSize,
|
||||
hasCaption = caption != null,
|
||||
isLoading = !contentValidationValue.isValidated(),
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
if (caption != null) {
|
||||
TimelineItemAttachmentCaptionView(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
caption = caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
iconContentDescription: String?,
|
||||
filename: String,
|
||||
fileExtensionAndSize: String,
|
||||
hasCaption: Boolean,
|
||||
isLoading: Boolean,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -86,12 +80,19 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
@@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = if (hasCaption) {
|
||||
{}
|
||||
} else {
|
||||
ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
extraWidth = iconSize + spacing
|
||||
)
|
||||
},
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
extraWidth = iconSize + spacing
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemAttachmentCaptionView(
|
||||
caption: String,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = caption,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<Timeline
|
||||
),
|
||||
aTimelineItemAttachmentsContent(
|
||||
body = "Files",
|
||||
caption = "Files mixed with media",
|
||||
attachments = listOf(
|
||||
anAttachmentItem(
|
||||
filename = "report.pdf",
|
||||
|
||||
+111
-58
@@ -7,31 +7,30 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -39,8 +38,6 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
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.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.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.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAttachmentsListView(
|
||||
eventId: EventId?,
|
||||
content: TimelineItemAttachmentsContent,
|
||||
onGalleryItemClick: (Int) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
content.attachments.forEachIndexed { index, attachment ->
|
||||
Column {
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
val contentValidationState = rememberEventContentValidationState(
|
||||
eventId = eventId,
|
||||
needsValidation = content.isMedia,
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
val validationStates = remember(content.attachments) {
|
||||
buildList {
|
||||
for (attachment in content.attachments) {
|
||||
add(validationStateForAttachment(attachment, contentValidationState))
|
||||
}
|
||||
}.toMutableStateList()
|
||||
}
|
||||
content.attachments.forEachIndexed { index, attachment ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl)
|
||||
val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl)
|
||||
|
||||
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
|
||||
validationStateForAttachment(attachment, contentValidationState)
|
||||
}
|
||||
|
||||
LaunchedEffect(itemContentValidationState) {
|
||||
validationStates[index] = itemContentValidationState
|
||||
}
|
||||
|
||||
val needsSeparator = index in 1..max(1, content.attachments.lastIndex)
|
||||
val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true
|
||||
if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
when (itemContentValidationState) {
|
||||
ContentValidationValue.Invalid -> {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
InvalidContentView(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
|
||||
.clip(shape),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
AttachmentListItem(
|
||||
attachment = attachment,
|
||||
onClick = { onGalleryItemClick(index) },
|
||||
is ContentValidationValue.UnrecoverableError -> {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
NotFoundContentView(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
|
||||
.clip(shape),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
ContentValidationValue.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally))
|
||||
}
|
||||
ContentValidationValue.Valid -> {
|
||||
AttachmentListItem(
|
||||
attachment = attachment,
|
||||
onClick = { onGalleryItemClick(index) },
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// TODO handle cases where either the content hasn't started validation or it failed to do so
|
||||
}
|
||||
}
|
||||
|
||||
if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.showCaption) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.widthIn(min = 120.dp),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validationStateForAttachment(
|
||||
attachment: AttachmentItem,
|
||||
contentValidationState: ContentValidationState,
|
||||
): ContentValidationValue {
|
||||
val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown
|
||||
val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl)
|
||||
return validationStateForAttachment(thumbnailValue, mediaValue)
|
||||
}
|
||||
|
||||
private fun validationStateForAttachment(
|
||||
thumbnailValue: ContentValidationValue,
|
||||
mediaValue: ContentValidationValue,
|
||||
): ContentValidationValue {
|
||||
return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) {
|
||||
ContentValidationValue.Invalid
|
||||
} else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) {
|
||||
listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() }
|
||||
} else if (thumbnailValue.isLoading() || mediaValue.isLoading()) {
|
||||
ContentValidationValue.Loading
|
||||
} else {
|
||||
mediaValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +268,8 @@ internal fun TimelineItemAttachmentsListViewPreview(
|
||||
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
|
||||
) = ElementPreview {
|
||||
TimelineItemAttachmentsListView(
|
||||
eventId = EventId("\$eventId"),
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,21 +16,23 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAudioView(
|
||||
content: TimelineItemAudioContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Audio(),
|
||||
iconContentDescription = null,
|
||||
filename = content.filename,
|
||||
fileExtensionAndSize = content.fileExtensionAndSize,
|
||||
caption = content.caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+253
-106
@@ -8,9 +8,23 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
@@ -32,132 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
eventId: EventId?,
|
||||
content: TimelineItemEventContent,
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onGalleryItemClick: ((Int) -> Unit),
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) {
|
||||
timelineProtectionState.hideMediaContent(eventId)
|
||||
}
|
||||
|
||||
val onShowContentClick = remember(timelineProtectionState.eventSink) {
|
||||
{
|
||||
timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId))
|
||||
}
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemGalleryContent -> TimelineItemGalleryView(
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLongClick = onLongClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = {},
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowClick = onShowContentClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemAudioContent -> TimelineItemAudioView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier)
|
||||
is TimelineItemStateContent -> TimelineItemStateView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemPollContent -> TimelineItemPollView(
|
||||
content = content,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVoiceContent -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
|
||||
TimelineItemVoiceView(
|
||||
state = presenter.present(),
|
||||
content = content,
|
||||
}
|
||||
|
||||
val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia)
|
||||
val overallValidationState by contentValidationState.collectOverallState()
|
||||
val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent }
|
||||
|
||||
if (eventId != null) {
|
||||
ValidateMediaHelper(eventId, content, contentValidationState, eventSink)
|
||||
}
|
||||
|
||||
val caption = content.captionOrNull()
|
||||
val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent
|
||||
// If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content
|
||||
val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) {
|
||||
if (showCaption) {
|
||||
{}
|
||||
} else {
|
||||
onContentLayoutChange
|
||||
}
|
||||
}
|
||||
|
||||
val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem
|
||||
val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem
|
||||
EqualWidthColumn(modifier = modifier) {
|
||||
if (displayInvalidContent) {
|
||||
InvalidContentView(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
// Icon + horizontal paddings
|
||||
extraWidth = 24.dp + 20.dp,
|
||||
)
|
||||
)
|
||||
} else if (displayContentNotFound) {
|
||||
NotFoundContentView(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
// Icon + horizontal paddings
|
||||
extraWidth = 24.dp + 20.dp,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
)
|
||||
}
|
||||
is TimelineItemImageContent -> {
|
||||
TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemStickerContent -> {
|
||||
TimelineItemStickerView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
TimelineItemVideoView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemGalleryContent -> TimelineItemGalleryView(
|
||||
eventId = eventId,
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
|
||||
eventId = eventId,
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
)
|
||||
is TimelineItemFileContent -> {
|
||||
TimelineItemFileView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
contentValidationValue = overallValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemAudioContent -> {
|
||||
TimelineItemAudioView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
contentValidationValue = overallValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView()
|
||||
is TimelineItemStateContent -> TimelineItemStateView(
|
||||
content = content,
|
||||
)
|
||||
is TimelineItemPollContent -> TimelineItemPollView(
|
||||
content = content,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
is TimelineItemVoiceContent -> {
|
||||
val presenter: Presenter<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,
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
@@ -27,15 +28,16 @@ fun TimelineItemFileView(
|
||||
content: TimelineItemFileContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Attachment(),
|
||||
iconContentDescription = stringResource(CommonStrings.common_file),
|
||||
filename = content.filename,
|
||||
fileExtensionAndSize = content.fileExtensionAndSize,
|
||||
caption = content.caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -116,6 +116,7 @@ fun aGalleryItem(
|
||||
width: Int = 400,
|
||||
height: Int = 300,
|
||||
duration: Duration = Duration.ZERO,
|
||||
mediaSource: MediaSource = MediaSource(url = "", json = ""),
|
||||
): GalleryItem {
|
||||
return GalleryItem(
|
||||
filename = filename,
|
||||
@@ -125,7 +126,7 @@ fun aGalleryItem(
|
||||
GalleryItem.Type.File -> "application/pdf"
|
||||
GalleryItem.Type.Image -> "image/jpeg"
|
||||
},
|
||||
mediaSource = MediaSource(url = "", json = ""),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
width = width,
|
||||
height = height,
|
||||
|
||||
+54
-42
@@ -7,7 +7,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,28 +20,24 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
@@ -50,10 +45,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val MAX_TILES = 5
|
||||
@@ -67,12 +63,10 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp
|
||||
|
||||
@Composable
|
||||
fun TimelineItemGalleryView(
|
||||
eventId: EventId?,
|
||||
content: TimelineItemGalleryContent,
|
||||
onGalleryItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val totalItems = content.items.size
|
||||
@@ -87,21 +81,25 @@ fun TimelineItemGalleryView(
|
||||
when (totalItems) {
|
||||
0 -> Unit
|
||||
1 -> SingleItemLayout(
|
||||
eventId = eventId,
|
||||
item = content.items[0],
|
||||
onClick = { onGalleryItemClick(0) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
2 -> TwoItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
3 -> ThreeItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
else -> FourPlusItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
showOverflow = showOverflow,
|
||||
overflowCount = overflowCount,
|
||||
@@ -110,40 +108,18 @@ fun TimelineItemGalleryView(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.widthIn(min = 120.dp),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleItemLayout(
|
||||
eventId: EventId?,
|
||||
item: GalleryItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -157,6 +133,7 @@ private fun SingleItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun TwoItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
@@ -167,6 +144,7 @@ private fun TwoItemLayout(
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -182,6 +160,7 @@ private fun TwoItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun ThreeItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
@@ -191,6 +170,7 @@ private fun ThreeItemLayout(
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[0],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -206,6 +186,7 @@ private fun ThreeItemLayout(
|
||||
) {
|
||||
for (it in 1..2) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -222,6 +203,7 @@ private fun ThreeItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun FourPlusItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
showOverflow: Boolean,
|
||||
overflowCount: Int,
|
||||
@@ -238,6 +220,7 @@ private fun FourPlusItemLayout(
|
||||
) {
|
||||
for (it in 0..1) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -260,6 +243,7 @@ private fun FourPlusItemLayout(
|
||||
if (itemIndex < items.size) {
|
||||
val isOverflowItem = showOverflow && i == bottomRowItems - 1
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[itemIndex],
|
||||
isLast = isOverflowItem,
|
||||
remainingCount = if (isOverflowItem) overflowCount else 0,
|
||||
@@ -277,6 +261,7 @@ private fun FourPlusItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun GalleryItemCell(
|
||||
eventId: EventId?,
|
||||
item: GalleryItem,
|
||||
isLast: Boolean,
|
||||
remainingCount: Int,
|
||||
@@ -284,6 +269,22 @@ private fun GalleryItemCell(
|
||||
onLongClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true)
|
||||
val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl)
|
||||
val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl)
|
||||
|
||||
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
|
||||
if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) {
|
||||
ContentValidationValue.Invalid
|
||||
} else if (thumbnailContentValidationState.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) {
|
||||
listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError }
|
||||
} else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) {
|
||||
ContentValidationValue.Loading
|
||||
} else {
|
||||
mediaContentValidationState
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||
@@ -305,7 +306,20 @@ private fun GalleryItemCell(
|
||||
VideoOverlay(duration = item.duration)
|
||||
}
|
||||
|
||||
if (isLast && remainingCount > 0) {
|
||||
if (itemContentValidationState.isLoading()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (itemContentValidationState.hasError()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} else if (isLast && remainingCount > 0) {
|
||||
RemainingCountOverlay(count = remainingCount)
|
||||
}
|
||||
}
|
||||
@@ -374,11 +388,9 @@ internal fun TimelineItemGalleryViewPreview(
|
||||
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
|
||||
) = ElementPreview {
|
||||
TimelineItemGalleryView(
|
||||
eventId = null,
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
+56
-121
@@ -8,20 +8,13 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -31,118 +24,97 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
private const val TALL_IMAGE_RATIO_DIVISOR = 3
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onShowContentClick: () -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally),
|
||||
modifier = containerModifier
|
||||
.blurHashBackground(content.blurhash, alpha = 0.9f)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
|
||||
) {
|
||||
ProtectedView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowContentClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (!isTalkbackActive() && onContentClick != null) {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
if (isContentBeingValidated) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
val width = content.width ?: 0
|
||||
val height = content.height ?: 0
|
||||
// if image is narrow and tall use DEFAULT_ASPECT_RATIO
|
||||
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) {
|
||||
DEFAULT_ASPECT_RATIO
|
||||
} else {
|
||||
content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
ProtectedView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowContentClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (!isTalkbackActive() && onContentClick != null) {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { state ->
|
||||
val url = content.thumbnailMediaRequestData.source?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,9 +129,6 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,43 +141,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "Image with null aspectRatio",
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
@@ -220,9 +155,9 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 80,
|
||||
height = 300,
|
||||
aspectRatio = 80f / 300f,
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
@@ -235,7 +170,7 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
filename = "image.jpg",
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 300,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
|
||||
+28
-5
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
private const val STICKER_SIZE_IN_DP = 128
|
||||
@@ -48,10 +53,14 @@ fun TimelineItemStickerView(
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally),
|
||||
) {
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
|
||||
@@ -63,11 +72,12 @@ fun TimelineItemStickerView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
var isThumbnailLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (onContentClick != null) {
|
||||
Modifier
|
||||
@@ -91,9 +101,22 @@ fun TimelineItemStickerView(
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
onState = { state ->
|
||||
val url = content.preferredMediaSource?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isThumbnailLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isContentBeingValidated) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
|
||||
import io.element.android.libraries.androidutils.text.LinkifyHelper
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
@@ -43,6 +44,9 @@ fun TimelineItemTextView(
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
|
||||
if (LocalUiTestMode.current) return
|
||||
|
||||
val emojiOnly = content.formattedBody.toString() == content.body &&
|
||||
content.body.replace(" ", "").containsOnlyEmojis()
|
||||
val textStyle = when {
|
||||
|
||||
+35
-96
@@ -8,22 +8,17 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||
@@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVideoView(
|
||||
@@ -75,15 +63,13 @@ fun TimelineItemVideoView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val isTalkbackActive = isTalkbackActive()
|
||||
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
@@ -91,6 +77,9 @@ fun TimelineItemVideoView(
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
|
||||
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
|
||||
@@ -127,48 +116,35 @@ fun TimelineItemVideoView(
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
onState = { state ->
|
||||
val url = content.thumbnailSource?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
imageVector = CompoundIcons.PlaySolid(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.semantics { hideFromAccessibility() }
|
||||
)
|
||||
if (!isContentBeingValidated) {
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
imageVector = CompoundIcons.PlaySolid(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.semantics { hideFromAccessibility() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
|
||||
) {
|
||||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||
text = caption,
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +157,6 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,39 +169,5 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
caption = "Video with null aspect ratio",
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -51,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||
@@ -64,6 +65,7 @@ fun TimelineItemVoiceView(
|
||||
content: TimelineItemVoiceContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
fun playPause() {
|
||||
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||
@@ -103,12 +105,16 @@ fun TimelineItemVoiceView(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (!isTalkbackActive()) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
if (contentValidationValue.isValid()) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
}
|
||||
} else {
|
||||
ProgressButton(displayImmediately = true)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
+12
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.layout
|
||||
|
||||
import android.text.Layout
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -41,6 +43,7 @@ import kotlin.math.roundToInt
|
||||
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
|
||||
* @param content The 'content' component of the layout.
|
||||
*/
|
||||
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
|
||||
@Composable
|
||||
fun ContentAvoidingLayout(
|
||||
overlay: @Composable () -> Unit,
|
||||
@@ -52,6 +55,15 @@ fun ContentAvoidingLayout(
|
||||
) {
|
||||
val scope = remember { ContentAvoidingLayoutScopeInstance() }
|
||||
|
||||
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
|
||||
if (LocalUiTestMode.current) {
|
||||
Column {
|
||||
scope.content()
|
||||
overlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = {
|
||||
|
||||
-2
@@ -19,8 +19,6 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
|
||||
aTimelineItemAudioContent("A 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(caption = "A caption"),
|
||||
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -15,6 +15,9 @@ import kotlin.time.Duration
|
||||
@Immutable
|
||||
sealed interface TimelineItemEventContent {
|
||||
val type: String
|
||||
|
||||
val isMedia: Boolean
|
||||
get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent || this is TimelineItemAttachmentsContent
|
||||
}
|
||||
|
||||
interface TimelineItemEventMutableContent {
|
||||
@@ -115,6 +118,13 @@ fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (this) {
|
||||
is TimelineItemEventContentWithAttachment -> formattedCaption
|
||||
is TimelineItemGalleryContent -> formattedCaption
|
||||
is TimelineItemAttachmentsContent -> formattedCaption
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
return when (this) {
|
||||
is TimelineItemAudioContent -> duration
|
||||
@@ -123,3 +133,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.blurHash(): String? {
|
||||
return when (this) {
|
||||
is TimelineItemImageContent -> blurhash
|
||||
is TimelineItemVideoContent -> blurHash
|
||||
is TimelineItemStickerContent -> blurhash
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -135,6 +135,7 @@ fun anAttachmentItem(
|
||||
fileSize: Long? = 1_000_000L,
|
||||
formattedFileSize: String = "1MB",
|
||||
mimeType: String? = null,
|
||||
mediaSource: MediaSource = MediaSource(url = "", json = ""),
|
||||
thumbnailSource: MediaSource? = null,
|
||||
hasThumbnail: Boolean = false,
|
||||
) = AttachmentItem(
|
||||
@@ -143,7 +144,7 @@ fun anAttachmentItem(
|
||||
hasThumbnail -> "image/jpeg"
|
||||
else -> "application/$fileExtension"
|
||||
},
|
||||
mediaSource = MediaSource(url = "", json = ""),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
|
||||
fileSize = fileSize,
|
||||
formattedFileSize = formattedFileSize,
|
||||
|
||||
-2
@@ -18,8 +18,6 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
|
||||
aTimelineItemFileContent(),
|
||||
aTimelineItemFileContent("A bigger name file.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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -20,6 +20,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||
aTimelineItemVideoContent(),
|
||||
aTimelineItemVideoContent(aspectRatio = 1.0f),
|
||||
aTimelineItemVideoContent(aspectRatio = 1.5f),
|
||||
aTimelineItemVideoContent(thumbnailMediaSource = MediaSource("https://example.com/thumbnail.jpg")),
|
||||
aTimelineItemVideoContent(blurhash = null),
|
||||
)
|
||||
}
|
||||
@@ -27,17 +28,20 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||
fun aTimelineItemVideoContent(
|
||||
aspectRatio: Float = 0.5f,
|
||||
blurhash: String? = A_BLUR_HASH,
|
||||
caption: String? = null,
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
thumbnailMediaSource: MediaSource? = null,
|
||||
) = TimelineItemVideoContent(
|
||||
filename = "Video.mp4",
|
||||
fileSize = 14 * 1024 * 1024L,
|
||||
caption = null,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
thumbnailSource = null,
|
||||
thumbnailSource = thumbnailMediaSource,
|
||||
blurHash = blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
duration = 100.milliseconds,
|
||||
mediaSource = MediaSource(""),
|
||||
mediaSource = mediaSource,
|
||||
width = 150,
|
||||
height = 300,
|
||||
thumbnailWidth = 150,
|
||||
|
||||
+7
@@ -9,7 +9,14 @@
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
|
||||
sealed interface TimelineProtectionEvent {
|
||||
data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent
|
||||
data class ValidateContent(
|
||||
val eventId: EventId,
|
||||
val mediaSources: List<MediaSource>,
|
||||
val validationState: ContentValidationState,
|
||||
) : TimelineProtectionEvent
|
||||
}
|
||||
|
||||
+13
-4
@@ -12,23 +12,28 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@Inject
|
||||
class TimelineProtectionPresenter(
|
||||
private val mediaPreviewService: MediaPreviewService,
|
||||
private val room: BaseRoom,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
) : Presenter<TimelineProtectionState> {
|
||||
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineProtectionState {
|
||||
@@ -36,13 +41,14 @@ class TimelineProtectionPresenter(
|
||||
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
|
||||
}.collectAsState()
|
||||
val roomInfo = room.roomInfoFlow.collectAsState()
|
||||
|
||||
val protectionState by remember {
|
||||
derivedStateOf {
|
||||
val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule)
|
||||
if (isPreviewEnabled) {
|
||||
ProtectionState.RenderAll
|
||||
} else {
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet())
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +56,10 @@ class TimelineProtectionPresenter(
|
||||
fun handleEvent(event: TimelineProtectionEvent) {
|
||||
when (event) {
|
||||
is TimelineProtectionEvent.ShowContent -> {
|
||||
allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId)
|
||||
allowedEvents += setOfNotNull(event.eventId)
|
||||
}
|
||||
is TimelineProtectionEvent.ValidateContent -> {
|
||||
contentScannerService.scan(event.eventId, event.mediaSources, event.validationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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)
|
||||
}
|
||||
+3
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
@@ -315,6 +316,8 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+3
@@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
@@ -1649,6 +1650,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+5
@@ -19,6 +19,8 @@ import io.element.android.features.messages.impl.timeline.components.MessageShie
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -28,6 +30,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -1585,6 +1588,7 @@ class TimelinePresenterTest {
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1605,6 +1609,7 @@ class TimelinePresenterTest {
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
timelineProtectionPresenter = timelineProtectionPresenter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -21,7 +21,7 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
@@ -67,7 +67,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
@@ -79,7 +79,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
@@ -98,7 +98,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
@@ -133,7 +133,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
timelineItems = persistentListOf<TimelineItem>(
|
||||
aTimelineItemEvent(
|
||||
// Do not use a Text because EditorStyledText cannot be used in UI test.
|
||||
content = aTimelineItemImageContent(),
|
||||
content = aTimelineItemTextContent(),
|
||||
messageShield = MessageShield.UnverifiedIdentity(true),
|
||||
),
|
||||
),
|
||||
@@ -155,7 +155,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
messageShield = aCriticalShield(),
|
||||
|
||||
+47
@@ -9,19 +9,28 @@
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -87,11 +96,49 @@ class TimelineProtectionPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - validate media scans the media source`() = runTest {
|
||||
val url = "https://example.com/media"
|
||||
|
||||
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
|
||||
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite), roomCoroutineScope = backgroundScope)
|
||||
val contentScannerService = lambdaRecorder { _: EventId, _: List<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(
|
||||
room: BaseRoom = FakeBaseRoom(),
|
||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: List<MediaSource>, _: ContentValidationState -> },
|
||||
) = TimelineProtectionPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -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))
|
||||
}
|
||||
}
|
||||
+25
-14
@@ -29,21 +29,32 @@ fun EqualWidthColumn(
|
||||
) {
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val measurables = subcompose(0, content).map { it.measure(constraints) }
|
||||
val maxWidth = measurables.maxOf { it.width }
|
||||
val newConstraints = constraints.copy(minWidth = maxWidth)
|
||||
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
|
||||
// Skip re-measuring if all children have the same width
|
||||
measurables
|
||||
if (measurables.isEmpty()) {
|
||||
// No children, so just return a layout with 0 width and height
|
||||
layout(0, 0) {}
|
||||
} else if (measurables.size == 1) {
|
||||
// No need to re-measure if there's only one child
|
||||
val measurable = measurables.first()
|
||||
layout(measurable.width, measurable.height) {
|
||||
measurable.placeRelative(0, 0)
|
||||
}
|
||||
} else {
|
||||
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
|
||||
subcompose(1, content).map { it.measure(newConstraints) }
|
||||
}
|
||||
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
|
||||
layout(maxWidth, totalHeight) {
|
||||
var yPosition = 0
|
||||
newMeasurables.forEach { measurable ->
|
||||
measurable.placeRelative(0, yPosition)
|
||||
yPosition += measurable.height + spacing.roundToPx()
|
||||
val maxWidth = measurables.maxOf { it.width }
|
||||
val newConstraints = constraints.copy(minWidth = maxWidth)
|
||||
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
|
||||
// Skip re-measuring if all children have the same width
|
||||
measurables
|
||||
} else {
|
||||
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
|
||||
subcompose(1, content).map { it.measure(newConstraints) }
|
||||
}
|
||||
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
|
||||
layout(maxWidth, totalHeight) {
|
||||
var yPosition = 0
|
||||
newMeasurables.forEach { measurable ->
|
||||
measurable.placeRelative(0, yPosition)
|
||||
yPosition += measurable.height + spacing.roundToPx()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -124,3 +124,34 @@ data class CallNotifyContent(
|
||||
) : EventContent
|
||||
|
||||
data object UnknownContent : EventContent
|
||||
|
||||
fun EventContent.isMediaContent(): Boolean {
|
||||
return when (this) {
|
||||
is MessageContent -> type is MessageTypeWithAttachment || type is GalleryMessageType
|
||||
is StickerContent -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun EventContent.mediaSources(): List<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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -115,6 +115,14 @@ sealed interface GalleryItemType {
|
||||
data class Video(val content: VideoMessageType) : GalleryItemType
|
||||
data class File(val content: FileMessageType) : GalleryItemType
|
||||
data class Other(val itemType: String, val body: String) : GalleryItemType
|
||||
|
||||
fun mediaSources(): List<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(
|
||||
|
||||
+3
-3
@@ -40,7 +40,7 @@ internal class CoilMediaFetcher(
|
||||
onSuccess = { it },
|
||||
onFailure = { error ->
|
||||
if (error.isNetworkError()) {
|
||||
null
|
||||
throw error
|
||||
} else {
|
||||
fetchContent(mediaSource)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ internal class CoilMediaFetcher(
|
||||
.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
.getOrNull()
|
||||
.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? {
|
||||
@@ -83,7 +83,7 @@ internal class CoilMediaFetcher(
|
||||
byteArray.asSourceResult()
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}.getOrNull()
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> {
|
||||
|
||||
+93
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -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 }
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
+72
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
+6
@@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) {
|
||||
is InReplyToDetails.Error -> eventId
|
||||
}
|
||||
|
||||
fun InReplyToDetails.content() = when (this) {
|
||||
is InReplyToDetails.Ready -> eventContent
|
||||
is InReplyToDetails.Loading -> null
|
||||
is InReplyToDetails.Error -> null
|
||||
}
|
||||
|
||||
fun InReplyTo.map(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = when (this) {
|
||||
|
||||
+50
-3
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,6 +45,9 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -55,18 +59,22 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun InReplyToView(
|
||||
inReplyTo: InReplyToDetails,
|
||||
hideImage: Boolean,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 2,
|
||||
) {
|
||||
when (inReplyTo) {
|
||||
is InReplyToDetails.Ready -> {
|
||||
ReplyToReadyContent(
|
||||
is InReplyToDetails.Ready -> when (contentValidationValue) {
|
||||
ContentValidationValue.Valid -> ReplyToReadyContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(hideImage),
|
||||
maxLines = maxLines,
|
||||
modifier = modifier,
|
||||
)
|
||||
ContentValidationValue.Invalid -> ReplyToInvalidContent()
|
||||
is ContentValidationValue.UnrecoverableError -> ReplyToNotFoundContent()
|
||||
else -> ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
|
||||
@@ -86,7 +94,7 @@ private fun ReplyToReadyContent(
|
||||
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
|
||||
PaddingValues(end = 8.dp)
|
||||
} else {
|
||||
PaddingValues(start = 8.dp, end = 8.dp)
|
||||
PaddingValues(horizontal = 8.dp)
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
@@ -213,11 +221,50 @@ private fun ReplyToContentText(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToInvalidContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
InvalidContentView(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToNotFoundContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NotFoundContentView(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview {
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
hideImage = false,
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ReplyToInvalidContentPreview() {
|
||||
ElementPreview {
|
||||
ReplyToInvalidContent(modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ReplyToNotFoundContentPreview() {
|
||||
ElementPreview {
|
||||
ReplyToNotFoundContent(modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ data class GalleryItemData(
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val type: Type,
|
||||
val blurHash: String?,
|
||||
) : Parcelable {
|
||||
enum class Type {
|
||||
Image,
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Params
|
||||
|
||||
data class EventGallery(
|
||||
@@ -55,6 +56,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
val avatarInfo: AvatarInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Params
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
|
||||
),
|
||||
mediaSource = MediaSource(url = avatarUrl),
|
||||
thumbnailSource = null,
|
||||
// Maybe add avatar blurhash if available?
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -116,6 +116,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
@@ -136,6 +137,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
@@ -242,6 +244,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -264,6 +267,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -286,6 +290,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.blurHash
|
||||
import io.element.android.libraries.mediaviewer.impl.model.eventId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
|
||||
@@ -68,6 +69,7 @@ class MediaGalleryFlowNode(
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ class MediaGalleryFlowNode(
|
||||
mediaInfo = item.mediaInfo(),
|
||||
mediaSource = item.mediaSource(),
|
||||
thumbnailSource = item.thumbnailSource(),
|
||||
blurHash = item.blurHash(),
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -134,6 +137,7 @@ class MediaGalleryFlowNode(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
blurHash = navTarget.blurHash,
|
||||
),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -51,6 +52,7 @@ fun VideoItemView(
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(blurHash = video.blurHash)
|
||||
.aspectRatio(1f)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
|
||||
+10
@@ -35,6 +35,7 @@ sealed interface MediaItem {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -46,6 +47,7 @@ sealed interface MediaItem {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
|
||||
is MediaItem.Voice -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.blurHash(): String? {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> blurHash
|
||||
is MediaItem.Video -> blurHash
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ fun aMediaItemImage(
|
||||
),
|
||||
mediaSource = MediaSource(mediaSourceUrl),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ fun aMediaItemVideo(
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
@@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
|
||||
+3
@@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource(
|
||||
),
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = params.blurHash,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
@@ -79,6 +80,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
@@ -88,6 +90,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest {
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = MediaSource(url = "avatarUrl"),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
assertThat(result.plugins).contains(callback)
|
||||
|
||||
+5
@@ -226,6 +226,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -326,6 +327,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -437,6 +439,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -498,6 +501,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -744,6 +748,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
),
|
||||
mediaSource = MediaSource("url"),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
blurHash = A_BLUR_HASH,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf()
|
||||
|
||||
+10
@@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Video,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Audio,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "document.pdf",
|
||||
@@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "video.mp4",
|
||||
@@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Video,
|
||||
blurHash = null,
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
|
||||
+2
@@ -114,6 +114,7 @@ class MediaViewerPresenterTest {
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
),
|
||||
room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
@@ -983,4 +984,5 @@ internal fun createMediaViewerEntryPointParams(
|
||||
mediaInfo = TESTED_MEDIA_INFO,
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
|
||||
+3
@@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = aMediaSource(url = "aUrl"),
|
||||
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
+27
-6
@@ -13,6 +13,7 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -35,8 +37,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -134,28 +139,44 @@ private fun ReplyToModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent)
|
||||
val currentValidationState by contentValidationState.collectOverallState()
|
||||
|
||||
val contentHasErrors = currentValidationState.hasError()
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = if (contentHasErrors) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault,
|
||||
shape = shape,
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (contentHasErrors) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
// Larger density DPI and font scale means less space to display the content, so we limit it to 1 line to avoid overflow issues
|
||||
val currentDensity = LocalDensity.current
|
||||
val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE
|
||||
val maxReplyContentLines = if (hasLowResolution) 1 else 2
|
||||
|
||||
InReplyToView(
|
||||
inReplyTo = replyToDetails,
|
||||
hideImage = hideImage,
|
||||
contentValidationValue = currentValidationState,
|
||||
maxLines = maxReplyContentLines,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
tint = if (contentHasErrors) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ class KonsistArchitectureTest {
|
||||
.classes()
|
||||
.withNameEndingWith("State")
|
||||
.withoutName(
|
||||
"NoopContentValidationState",
|
||||
"CameraPositionState",
|
||||
"CustomSheetState",
|
||||
)
|
||||
|
||||
+11
-2
@@ -145,7 +145,8 @@ class KonsistPreviewTest {
|
||||
"TextComposerVoiceNotEncryptedPreview",
|
||||
"TextComposerVoicePreview",
|
||||
"TextFieldDialogWithErrorPreview",
|
||||
"TimelineImageWithCaptionRowPreview",
|
||||
"TimelineItemAttachmentsViewScanningContentFailedPreview",
|
||||
"TimelineItemAudioViewScanningContentPreview",
|
||||
"TimelineItemEventRowForDirectRoomPreview",
|
||||
"TimelineItemEventRowShieldPreview",
|
||||
"TimelineItemEventRowTimestampPreview",
|
||||
@@ -155,13 +156,21 @@ class KonsistPreviewTest {
|
||||
"TimelineItemEventRowWithRRPreview",
|
||||
"TimelineItemEventRowWithReplyPreview",
|
||||
"TimelineItemEventRowWithThreadSummaryPreview",
|
||||
"TimelineItemFileViewScanningContentPreview",
|
||||
"TimelineItemGalleryViewScanningContentFailedPreview",
|
||||
"TimelineItemGroupedEventsRowContentCollapsePreview",
|
||||
"TimelineItemGroupedEventsRowContentExpandedPreview",
|
||||
"TimelineItemImageViewHideMediaContentPreview",
|
||||
"TimelineItemImageViewScanningContentPreview",
|
||||
"TimelineItemRedactedMessagesGroupPreview",
|
||||
"TimelineItemScanningContentFailedPreview",
|
||||
"TimelineItemScanningContentWithInvalidRepliesPreview",
|
||||
"TimelineItemScanningContentWithRepliesFailedPreview",
|
||||
"TimelineItemStickerViewScanningContentPreview",
|
||||
"TimelineItemVideoViewHideMediaContentPreview",
|
||||
"TimelineItemVideoViewScanningContentPreview",
|
||||
"TimelineItemVoiceViewScanningContentPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"TimelineViewWithReadMarkerBothIndicatorsPreview",
|
||||
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
|
||||
|
||||
@@ -26,6 +26,8 @@ import com.android.resources.Density.DEFAULT_DENSITY
|
||||
import com.android.resources.NightMode
|
||||
import com.android.resources.ScreenOrientation
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
|
||||
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
|
||||
import java.util.Locale
|
||||
@@ -55,6 +57,7 @@ object ScreenshotTest {
|
||||
setLocales(LocaleList(locale))
|
||||
uiMode = preview.previewInfo.uiMode
|
||||
},
|
||||
LocalEventContentValidationState provides NoopEventContentValidationCache(),
|
||||
) {
|
||||
ElementTheme {
|
||||
Box(
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
|
||||
size 5325
|
||||
oid sha256:c5572491b675a16724cd9e33ebb477c82edf285fa07d57ebef09c670fcaaddf1
|
||||
size 21285
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b
|
||||
size 20793
|
||||
oid sha256:290a2be6838445739b0181006f9fea87337a85cfed3573c153e9f503c66624a6
|
||||
size 4994
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
|
||||
size 295298
|
||||
oid sha256:ce15ca9f89274620728715e3448ce671e2253f4fed22a44035460e9a3709ca0b
|
||||
size 647404
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
|
||||
size 294992
|
||||
oid sha256:d0424685437a433b35b589668b409d55f26ab41bcae01af0121a54033c56a8c2
|
||||
size 646194
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
|
||||
size 612494
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
|
||||
size 611488
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa4034a06aa6e8d24806b640d9c6604bf4cffc5ec5664d2b9b189e6c39850ce0
|
||||
size 42543
|
||||
oid sha256:9e8d673869b3fd1d6be2b95c79c8a12e94732f74f28b51a38e40c166a9eae4f6
|
||||
size 39466
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9b777363de123a6de81360e4ee9c5066c8953ac103c52349979e4c3fdca1e20c
|
||||
size 41699
|
||||
oid sha256:99672a8ee0d4d22f0d15a74d4e3c8923b762116ecd2e741fd09c184963737e11
|
||||
size 38609
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c43d85e4658b9fb930ef76258479a876d5cde3b545bc4f4a76d86e4ef2d7a82f
|
||||
size 10773
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c836c0a00fb5697138349028540d5e1cb4f1fddd2fd50d388cb5a1b55ccaa8f4
|
||||
size 21023
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9fa672f2cb7dbf0fff171d507e66d1ed8494cbcd93b48bef1ee1b9b75d6717e7
|
||||
size 10499
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5735813e0f9c40e5d861ba0c35ba73689ff7ee41224829289ce017746323922c
|
||||
size 20150
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:10b79226a4eb5c67afc1fc8ace8c6fb49429a69b1e1518b622dcfd20bc2820ea
|
||||
size 10032
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:085310083c4150fb70e48087881e529f17005df69cc01dd53807a06266f4f4bb
|
||||
size 20317
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ef1884c4cac3debea52affbab071110a49b40cebfd38b316693085464fcc440
|
||||
size 9859
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user