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