mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +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
+25
-14
@@ -29,21 +29,32 @@ fun EqualWidthColumn(
|
||||
) {
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val measurables = subcompose(0, content).map { it.measure(constraints) }
|
||||
val maxWidth = measurables.maxOf { it.width }
|
||||
val newConstraints = constraints.copy(minWidth = maxWidth)
|
||||
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
|
||||
// Skip re-measuring if all children have the same width
|
||||
measurables
|
||||
if (measurables.isEmpty()) {
|
||||
// No children, so just return a layout with 0 width and height
|
||||
layout(0, 0) {}
|
||||
} else if (measurables.size == 1) {
|
||||
// No need to re-measure if there's only one child
|
||||
val measurable = measurables.first()
|
||||
layout(measurable.width, measurable.height) {
|
||||
measurable.placeRelative(0, 0)
|
||||
}
|
||||
} else {
|
||||
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
|
||||
subcompose(1, content).map { it.measure(newConstraints) }
|
||||
}
|
||||
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
|
||||
layout(maxWidth, totalHeight) {
|
||||
var yPosition = 0
|
||||
newMeasurables.forEach { measurable ->
|
||||
measurable.placeRelative(0, yPosition)
|
||||
yPosition += measurable.height + spacing.roundToPx()
|
||||
val maxWidth = measurables.maxOf { it.width }
|
||||
val newConstraints = constraints.copy(minWidth = maxWidth)
|
||||
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
|
||||
// Skip re-measuring if all children have the same width
|
||||
measurables
|
||||
} else {
|
||||
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
|
||||
subcompose(1, content).map { it.measure(newConstraints) }
|
||||
}
|
||||
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
|
||||
layout(maxWidth, totalHeight) {
|
||||
var yPosition = 0
|
||||
newMeasurables.forEach { measurable ->
|
||||
measurable.placeRelative(0, yPosition)
|
||||
yPosition += measurable.height + spacing.roundToPx()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -124,3 +124,34 @@ data class CallNotifyContent(
|
||||
) : EventContent
|
||||
|
||||
data object UnknownContent : EventContent
|
||||
|
||||
fun EventContent.isMediaContent(): Boolean {
|
||||
return when (this) {
|
||||
is MessageContent -> type is MessageTypeWithAttachment || type is GalleryMessageType
|
||||
is StickerContent -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun EventContent.mediaSources(): List<MediaSource> {
|
||||
return when (this) {
|
||||
is MessageContent -> mediaSources()
|
||||
is StickerContent -> listOfNotNull(source, info.thumbnailSource)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun MessageContent.mediaSources(): List<MediaSource> {
|
||||
return when (val messageType = type) {
|
||||
is MessageTypeWithAttachment -> when (messageType) {
|
||||
is ImageMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource)
|
||||
is VideoMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource)
|
||||
is AudioMessageType -> listOf(messageType.source)
|
||||
is VoiceMessageType -> listOf(messageType.source)
|
||||
is FileMessageType -> listOfNotNull(messageType.source, messageType.info?.thumbnailSource)
|
||||
else -> emptyList()
|
||||
}
|
||||
is GalleryMessageType -> messageType.items.flatMap { it.mediaSources() }
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -115,6 +115,14 @@ sealed interface GalleryItemType {
|
||||
data class Video(val content: VideoMessageType) : GalleryItemType
|
||||
data class File(val content: FileMessageType) : GalleryItemType
|
||||
data class Other(val itemType: String, val body: String) : GalleryItemType
|
||||
|
||||
fun mediaSources(): List<MediaSource> = when (this) {
|
||||
is Image -> listOfNotNull(content.source, content.info?.thumbnailSource)
|
||||
is Audio -> listOf(content.source)
|
||||
is Video -> listOfNotNull(content.source, content.info?.thumbnailSource)
|
||||
is File -> listOfNotNull(content.source, content.info?.thumbnailSource)
|
||||
is Other -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
data class OtherMessageType(
|
||||
|
||||
+3
-3
@@ -40,7 +40,7 @@ internal class CoilMediaFetcher(
|
||||
onSuccess = { it },
|
||||
onFailure = { error ->
|
||||
if (error.isNetworkError()) {
|
||||
null
|
||||
throw error
|
||||
} else {
|
||||
fetchContent(mediaSource)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ internal class CoilMediaFetcher(
|
||||
.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
.getOrNull()
|
||||
.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? {
|
||||
@@ -83,7 +83,7 @@ internal class CoilMediaFetcher(
|
||||
byteArray.asSourceResult()
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}.getOrNull()
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> {
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
|
||||
@Composable
|
||||
internal fun ContentErrorView(
|
||||
title: String,
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
) {
|
||||
val updatedOnTextLayout by rememberUpdatedState(onTextLayout)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(color = ElementTheme.colors.bgCriticalSubtle)
|
||||
.padding(contentPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconCriticalPrimary
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val textContent = remember(message) {
|
||||
movableContentOf(
|
||||
@Composable {
|
||||
Text(
|
||||
text = message,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// BoxWithConstraints is needed to be able to calculate the text layout in the current constraints, so we can pass it to the onTextLayout callback.
|
||||
// However, this can't be used inside a SubComposeLayout (like the one in the text composer), so we only use it when the onTextLayout callback
|
||||
// is provided.
|
||||
if (updatedOnTextLayout != null) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
LaunchedEffect(message) {
|
||||
updatedOnTextLayout?.invoke(textMeasurer.measure(message, overflow = TextOverflow.Visible, constraints = constraints))
|
||||
}
|
||||
|
||||
textContent()
|
||||
}
|
||||
} else {
|
||||
textContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Wrapper for the content validation state of an event.
|
||||
*
|
||||
* It can be used to check the overall validation state of the event, as well as the validation state of each media item in it.
|
||||
*/
|
||||
@Immutable
|
||||
interface ContentValidationState {
|
||||
/**
|
||||
* A flow that emits the overall validation state of the event, which is an aggregation of the different media values.
|
||||
*/
|
||||
val overallStateFlow: Flow<ContentValidationValue>
|
||||
|
||||
/**
|
||||
* Returns a flow that emits the validation state of a specific media item, identified by its URL.
|
||||
*/
|
||||
fun getMediaStateFlow(url: String): Flow<ContentValidationValue>
|
||||
|
||||
/**
|
||||
* Returns the current overall validation state of the event.
|
||||
*/
|
||||
fun getCurrentOverallState(): ContentValidationValue
|
||||
|
||||
/**
|
||||
* Returns the current validation state of a specific media item, identified by its URL.
|
||||
*/
|
||||
fun getCurrentMediaState(url: String): ContentValidationValue
|
||||
|
||||
/**
|
||||
* Updates the validation state of a specific media item, identified by its URL, with a new [ContentValidationValue].
|
||||
*/
|
||||
fun update(url: String, newValue: ContentValidationValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of [ContentValidationState] that always returns a fixed validation state.
|
||||
* By default, this is [ContentValidationValue.Valid].
|
||||
*/
|
||||
@Immutable
|
||||
class NoopContentValidationState(
|
||||
private val initial: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) : ContentValidationState {
|
||||
override val overallStateFlow: Flow<ContentValidationValue> = MutableStateFlow(initial)
|
||||
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> = MutableStateFlow(initial)
|
||||
override fun getCurrentOverallState(): ContentValidationValue = initial
|
||||
override fun getCurrentMediaState(url: String): ContentValidationValue = initial
|
||||
override fun update(url: String, newValue: ContentValidationValue) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A default implementation of [ContentValidationState] that tracks the validation state of media items in a mutable map.
|
||||
* It calculates the overall validation state based on the individual media states.
|
||||
*/
|
||||
@Immutable
|
||||
class DefaultContentValidationState(
|
||||
private val states: MutableStateFlow<Map<String, ContentValidationValue>>,
|
||||
) : ContentValidationState {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val overallStateFlow: Flow<ContentValidationValue> = states
|
||||
.mapLatest { states -> calculateOverallState(states) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
constructor() : this(states = MutableStateFlow(emptyMap()))
|
||||
|
||||
constructor(initial: Map<String, ContentValidationValue>) : this(states = MutableStateFlow(initial))
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> {
|
||||
return states.map { states ->
|
||||
states[url] ?: ContentValidationValue.Unknown
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun getCurrentOverallState(): ContentValidationValue {
|
||||
return calculateOverallState(states.value)
|
||||
}
|
||||
|
||||
override fun getCurrentMediaState(url: String): ContentValidationValue {
|
||||
return states.value[url] ?: ContentValidationValue.Unknown
|
||||
}
|
||||
|
||||
override fun update(url: String, newValue: ContentValidationValue) {
|
||||
states.update { current ->
|
||||
if (current[url]?.isValidated() == true && newValue.isLoading()) {
|
||||
// If the current state is already validated and the new value is loading, we keep the current validated state
|
||||
current
|
||||
} else {
|
||||
// Otherwise, we update the state for the given URL with the new value
|
||||
current + (url to newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
||||
return when {
|
||||
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
||||
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
||||
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
|
||||
states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid
|
||||
else -> ContentValidationValue.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the validation state of a media item or an event.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface ContentValidationValue {
|
||||
/** Indicates that the content is valid. */
|
||||
data object Valid : ContentValidationValue
|
||||
|
||||
/** Indicates that the content is invalid/unsafe. */
|
||||
data object Invalid : ContentValidationValue
|
||||
|
||||
/** Indicates that the content is currently being validated. */
|
||||
data object Loading : ContentValidationValue
|
||||
|
||||
/** Indicates that an unrecoverable error occurred during validation, and it should not be retried. */
|
||||
data class UnrecoverableError(val error: Throwable) : ContentValidationValue
|
||||
|
||||
/** Indicates that the validation state is unknown and should be checked. Recoverable errors are also represented with this. */
|
||||
data object Unknown : ContentValidationValue
|
||||
|
||||
/**
|
||||
* Returns true if the validation state is either [Valid], [Invalid], or [UnrecoverableError], meaning that the validation process has completed
|
||||
* and a definitive result is available.
|
||||
*/
|
||||
fun isValidated(): Boolean = when (this) {
|
||||
is Valid -> true
|
||||
is Invalid -> true
|
||||
is Loading -> false
|
||||
is UnrecoverableError -> true
|
||||
is Unknown -> false
|
||||
}
|
||||
|
||||
/** Returns true if the validation state is [Valid]. */
|
||||
fun isValid(): Boolean = this is Valid
|
||||
|
||||
/** Returns true if the validation state is [Invalid]. */
|
||||
fun isInvalid(): Boolean = this is Invalid
|
||||
|
||||
/** Returns true if the validation state is [Loading]. */
|
||||
fun isLoading(): Boolean = this is Loading
|
||||
|
||||
/** Returns true if the validation state is [UnrecoverableError]. */
|
||||
fun hasUnrecoverableError(): Boolean = this is UnrecoverableError
|
||||
|
||||
/** Returns true if the validation state is either [Invalid] or [UnrecoverableError]. */
|
||||
fun hasError(): Boolean = this is Invalid || this is UnrecoverableError
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the overall validation state of the [ContentValidationState] as a [State] in a Composable context.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentValidationState.collectOverallState(): State<ContentValidationValue> {
|
||||
return produceState(getCurrentOverallState()) {
|
||||
overallStateFlow.collect { value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the validation state of a specific media item, identified by its URL, as a [State] in a Composable context.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentValidationState.collectMediaState(url: String?): State<ContentValidationValue> {
|
||||
if (url == null) {
|
||||
return remember { mutableStateOf(ContentValidationValue.Unknown) }
|
||||
}
|
||||
|
||||
return produceState(getCurrentMediaState(url), key1 = url) {
|
||||
getMediaStateFlow(url).collect { value = it }
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
/**
|
||||
* A cache that holds the validation state of event contents, based on their [EventId].
|
||||
*/
|
||||
interface EventContentValidationCache {
|
||||
/**
|
||||
* Returns the [ContentValidationState] for the given [eventId]. If none exists, it creates a new one with [ContentValidationValue.Unknown].
|
||||
*/
|
||||
operator fun get(eventId: EventId): ContentValidationState
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
/**
|
||||
* In-memory variant of [EventContentValidationCache] that holds the validation state of event contents, based on their [EventId].
|
||||
*
|
||||
* Note: this is intended to be used in tests.
|
||||
*/
|
||||
class InMemoryEventContentValidationCache(
|
||||
initial: Map<EventId, ContentValidationState> = emptyMap(),
|
||||
) : EventContentValidationCache {
|
||||
private val cache = initial.toMutableMap()
|
||||
|
||||
override operator fun get(eventId: EventId): ContentValidationState {
|
||||
return cache[eventId] ?: DefaultContentValidationState()
|
||||
}
|
||||
|
||||
fun put(eventId: EventId, state: ContentValidationState) {
|
||||
cache[eventId] = state
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* A view to display when the media content is invalid or dangerous.
|
||||
*/
|
||||
@Composable
|
||||
fun InvalidContentView(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
) {
|
||||
ContentErrorView(
|
||||
title = stringResource(CommonStrings.content_scanner_unsafe_title),
|
||||
message = stringResource(CommonStrings.content_scanner_unsafe_message),
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InvalidContentViewPreview() = ElementPreview {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
InvalidContentView()
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
|
||||
|
||||
private val noopValue = NoopContentValidationState()
|
||||
|
||||
/**
|
||||
* A composition local that provides an [EventContentValidationCache] instance, so we can check if a given event content has been validated or not.
|
||||
*/
|
||||
val LocalEventContentValidationState = staticCompositionLocalOf<EventContentValidationCache> { NoopEventContentValidationCache() }
|
||||
|
||||
/**
|
||||
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
|
||||
*/
|
||||
class NoopEventContentValidationCache : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = noopValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper to remember the validation state of an event content, based on its [EventId] and [EventContent], if known.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberEventContentValidationState(eventId: EventId?, eventContent: EventContent?): ContentValidationState {
|
||||
val needsValidation = remember(eventContent) {
|
||||
when (eventContent) {
|
||||
is MessageContent -> eventContent.isMediaContent()
|
||||
is StickerContent -> true
|
||||
// If the event content is not known or not a media content, we don't need to validate it.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
return rememberEventContentValidationState(eventId, needsValidation)
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper to remember the validation state of an event content, based on its [EventId] and whether it needs validation or not.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberEventContentValidationState(eventId: EventId?, needsValidation: Boolean): ContentValidationState {
|
||||
if (!needsValidation) {
|
||||
return noopValue
|
||||
}
|
||||
|
||||
return rememberEventContentValidationStateInternal(eventId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberEventContentValidationStateInternal(eventId: EventId?): ContentValidationState {
|
||||
val cache = LocalEventContentValidationState.current
|
||||
return remember(eventId) {
|
||||
if (eventId != null) {
|
||||
cache[eventId]
|
||||
} else {
|
||||
noopValue
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media.contentvalidation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* A view to display when the media content can't be fetched.
|
||||
*/
|
||||
@Composable
|
||||
fun NotFoundContentView(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
) {
|
||||
ContentErrorView(
|
||||
title = stringResource(CommonStrings.content_scanner_not_found_title),
|
||||
message = stringResource(CommonStrings.content_scanner_not_found),
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun NotFoundContentViewPreview() = ElementPreview {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NotFoundContentView()
|
||||
}
|
||||
}
|
||||
+6
@@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) {
|
||||
is InReplyToDetails.Error -> eventId
|
||||
}
|
||||
|
||||
fun InReplyToDetails.content() = when (this) {
|
||||
is InReplyToDetails.Ready -> eventContent
|
||||
is InReplyToDetails.Loading -> null
|
||||
is InReplyToDetails.Error -> null
|
||||
}
|
||||
|
||||
fun InReplyTo.map(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = when (this) {
|
||||
|
||||
+50
-3
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,6 +45,9 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -55,18 +59,22 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun InReplyToView(
|
||||
inReplyTo: InReplyToDetails,
|
||||
hideImage: Boolean,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 2,
|
||||
) {
|
||||
when (inReplyTo) {
|
||||
is InReplyToDetails.Ready -> {
|
||||
ReplyToReadyContent(
|
||||
is InReplyToDetails.Ready -> when (contentValidationValue) {
|
||||
ContentValidationValue.Valid -> ReplyToReadyContent(
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(hideImage),
|
||||
maxLines = maxLines,
|
||||
modifier = modifier,
|
||||
)
|
||||
ContentValidationValue.Invalid -> ReplyToInvalidContent()
|
||||
is ContentValidationValue.UnrecoverableError -> ReplyToNotFoundContent()
|
||||
else -> ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
|
||||
@@ -86,7 +94,7 @@ private fun ReplyToReadyContent(
|
||||
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
|
||||
PaddingValues(end = 8.dp)
|
||||
} else {
|
||||
PaddingValues(start = 8.dp, end = 8.dp)
|
||||
PaddingValues(horizontal = 8.dp)
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
@@ -213,11 +221,50 @@ private fun ReplyToContentText(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToInvalidContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
InvalidContentView(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToNotFoundContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NotFoundContentView(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview {
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
hideImage = false,
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ReplyToInvalidContentPreview() {
|
||||
ElementPreview {
|
||||
ReplyToInvalidContent(modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ReplyToNotFoundContentPreview() {
|
||||
ElementPreview {
|
||||
ReplyToNotFoundContent(modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ data class GalleryItemData(
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val type: Type,
|
||||
val blurHash: String?,
|
||||
) : Parcelable {
|
||||
enum class Type {
|
||||
Image,
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Params
|
||||
|
||||
data class EventGallery(
|
||||
@@ -55,6 +56,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
val avatarInfo: AvatarInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Params
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
|
||||
),
|
||||
mediaSource = MediaSource(url = avatarUrl),
|
||||
thumbnailSource = null,
|
||||
// Maybe add avatar blurhash if available?
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -116,6 +116,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
@@ -136,6 +137,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
@@ -242,6 +244,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -264,6 +267,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -286,6 +290,7 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.blurHash
|
||||
import io.element.android.libraries.mediaviewer.impl.model.eventId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
|
||||
@@ -68,6 +69,7 @@ class MediaGalleryFlowNode(
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ class MediaGalleryFlowNode(
|
||||
mediaInfo = item.mediaInfo(),
|
||||
mediaSource = item.mediaSource(),
|
||||
thumbnailSource = item.thumbnailSource(),
|
||||
blurHash = item.blurHash(),
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -134,6 +137,7 @@ class MediaGalleryFlowNode(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
blurHash = navTarget.blurHash,
|
||||
),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -51,6 +52,7 @@ fun VideoItemView(
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(blurHash = video.blurHash)
|
||||
.aspectRatio(1f)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
|
||||
+10
@@ -35,6 +35,7 @@ sealed interface MediaItem {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -46,6 +47,7 @@ sealed interface MediaItem {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
|
||||
is MediaItem.Voice -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.blurHash(): String? {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> blurHash
|
||||
is MediaItem.Video -> blurHash
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ fun aMediaItemImage(
|
||||
),
|
||||
mediaSource = MediaSource(mediaSourceUrl),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ fun aMediaItemVideo(
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
@@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
|
||||
+3
@@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource(
|
||||
),
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = params.blurHash,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
@@ -79,6 +80,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
@@ -88,6 +90,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest {
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = MediaSource(url = "avatarUrl"),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
assertThat(result.plugins).contains(callback)
|
||||
|
||||
+5
@@ -226,6 +226,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -326,6 +327,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -437,6 +439,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -498,6 +501,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -744,6 +748,7 @@ class EventItemFactoryTest {
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
),
|
||||
mediaSource = MediaSource("url"),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
blurHash = A_BLUR_HASH,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf()
|
||||
|
||||
+10
@@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Video,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Audio,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
@@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
blurHash = null,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "document.pdf",
|
||||
@@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "video.mp4",
|
||||
@@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Video,
|
||||
blurHash = null,
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
|
||||
+2
@@ -114,6 +114,7 @@ class MediaViewerPresenterTest {
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
),
|
||||
room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
@@ -983,4 +984,5 @@ internal fun createMediaViewerEntryPointParams(
|
||||
mediaInfo = TESTED_MEDIA_INFO,
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
)
|
||||
|
||||
+3
@@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = aMediaSource(url = "aUrl"),
|
||||
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
|
||||
blurHash = null,
|
||||
)
|
||||
}
|
||||
|
||||
+27
-6
@@ -13,6 +13,7 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -35,8 +37,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -134,28 +139,44 @@ private fun ReplyToModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent)
|
||||
val currentValidationState by contentValidationState.collectOverallState()
|
||||
|
||||
val contentHasErrors = currentValidationState.hasError()
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = if (contentHasErrors) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault,
|
||||
shape = shape,
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (contentHasErrors) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
// Larger density DPI and font scale means less space to display the content, so we limit it to 1 line to avoid overflow issues
|
||||
val currentDensity = LocalDensity.current
|
||||
val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE
|
||||
val maxReplyContentLines = if (hasLowResolution) 1 else 2
|
||||
|
||||
InReplyToView(
|
||||
inReplyTo = replyToDetails,
|
||||
hideImage = hideImage,
|
||||
contentValidationValue = currentValidationState,
|
||||
maxLines = maxReplyContentLines,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
tint = if (contentHasErrors) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
|
||||
Reference in New Issue
Block a user