Merge branch 'develop' into feature/valere/timeline_active_call_rendering

This commit is contained in:
Valere
2026-07-16 10:51:34 +02:00
266 changed files with 3081 additions and 923 deletions
@@ -16,7 +16,11 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.LocalRippleThemeConfiguration
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.RippleThemeConfiguration
import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -28,6 +32,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.compoundTypography
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.TypographyTokens
@@ -159,6 +164,26 @@ fun ElementTheme(
CompositionLocalProvider(
LocalCompoundColors provides currentCompoundColor,
LocalContentColor provides colorScheme.onSurface,
// Configure the keyboard focus style: Draw a blue inset ring around the focused component.
// Ref: https://www.figma.com/design/hlbsmSekQorGRN1t2R9JEy/Accessibility-checks?node-id=271-42066
// By default, a semi-transparent black overlay is used
LocalRippleThemeConfiguration provides RippleThemeConfiguration(
RippleThemeConfiguration.Focus.InsetRing(
outerStrokeInset = 0.dp,
outerStrokeWidth = 2.dp,
innerStrokeInset = 0.dp,
innerStrokeWidth = 0.dp,
)
),
// Configure the keyboard focus color.
// By default, ColorScheme.secondary is used
LocalRippleConfiguration provides
RippleConfiguration(
focus = RippleConfiguration.Focus.InsetRing(
outerStrokeColor = ElementTheme.colors.borderFocused,
innerStrokeColor = Color.Transparent,
)
),
) {
MaterialTheme(
colorScheme = colorScheme,
@@ -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()
}
}
}
}
@@ -28,13 +28,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalRippleThemeConfiguration
import androidx.compose.material3.RippleThemeConfiguration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
@@ -181,11 +183,8 @@ private fun ButtonInternal(
ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp)
}
val shape = when (style) {
ButtonStyle.Filled,
ButtonStyle.Outlined -> RoundedCornerShape(percent = 50)
ButtonStyle.Text -> RectangleShape
}
// Apply the same shape to all buttons, so that the focus ring is consistent across styles.
val shape = RoundedCornerShape(percent = 50)
val border = when (style) {
ButtonStyle.Filled -> null
@@ -202,48 +201,66 @@ private fun ButtonInternal(
ButtonStyle.Text -> null
}
androidx.compose.material3.Button(
onClick = {
if (!showProgress) {
onClick()
}
},
modifier = modifier.heightIn(min = minHeight),
enabled = enabled,
shape = shape,
colors = colors,
elevation = null,
border = border,
contentPadding = contentPadding,
interactionSource = remember { MutableInteractionSource() },
// Reduce outerStrokeInset on Filled buttons so that it's displayed around the button
val outerStrokeInset = when (style) {
ButtonStyle.Filled -> (-4).dp
ButtonStyle.Outlined,
ButtonStyle.Text -> 0.dp
}
CompositionLocalProvider(
LocalRippleThemeConfiguration provides RippleThemeConfiguration(
RippleThemeConfiguration.Focus.InsetRing(
outerStrokeInset = outerStrokeInset,
outerStrokeWidth = 2.dp,
innerStrokeInset = 0.dp,
innerStrokeWidth = 0.dp,
)
),
) {
when {
showProgress -> {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
color = LocalContentColor.current,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
leadingIcon != null -> {
Icon(
painter = leadingIcon.getPainter(),
contentDescription = null,
tint = LocalContentColor.current,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(
onClick = {
if (!showProgress) {
onClick()
}
},
modifier = modifier.heightIn(min = minHeight),
enabled = enabled,
shape = shape,
colors = colors,
elevation = null,
border = border,
contentPadding = contentPadding,
interactionSource = remember { MutableInteractionSource() },
) {
when {
showProgress -> {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
color = LocalContentColor.current,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
leadingIcon != null -> {
Icon(
painter = leadingIcon.getPainter(),
contentDescription = null,
tint = LocalContentColor.current,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
}
}
Text(
text = text,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = text,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -294,7 +311,7 @@ internal enum class ButtonStyle {
@Composable
fun getColors(destructive: Boolean): ButtonColors = when (this) {
Filled -> ButtonDefaults.buttonColors(
Filled -> ButtonDefaults.filledTonalButtonColors(
containerColor = getPrimaryColor(destructive),
contentColor = ElementTheme.materialColors.onPrimary,
disabledContainerColor = if (destructive) {
@@ -304,13 +321,13 @@ internal enum class ButtonStyle {
},
disabledContentColor = ElementTheme.colors.textOnSolidPrimary
)
Outlined -> ButtonDefaults.buttonColors(
Outlined -> ButtonDefaults.outlinedButtonColors(
containerColor = Color.Transparent,
contentColor = getPrimaryColor(destructive),
disabledContainerColor = Color.Transparent,
disabledContentColor = getDisabledContentColor(destructive),
)
Text -> ButtonDefaults.buttonColors(
Text -> ButtonDefaults.textButtonColors(
containerColor = Color.Transparent,
contentColor = if (destructive) {
ElementTheme.colors.textCriticalPrimary
@@ -139,7 +139,7 @@ private fun SearchFieldContainer(
border = BorderStroke(
width = 1.dp,
color = if (isFocused) {
ElementTheme.colors.borderInteractiveHovered
ElementTheme.colors.borderFocused
} else {
ElementTheme.colors.borderInteractiveSecondary
}
@@ -293,8 +293,8 @@ private fun TextFieldContainer(
width = if (isFocused) 2.dp else 1.dp,
color = when {
!enabled -> ElementTheme.colors.borderDisabled
isFocused -> ElementTheme.colors.borderFocused
isError -> ElementTheme.colors.borderCriticalPrimary
isFocused -> ElementTheme.colors.borderInteractiveHovered
else -> ElementTheme.colors.borderInteractiveSecondary
}
)
@@ -127,3 +127,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()
}
}
@@ -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(
@@ -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> {
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@Composable
internal fun ContentErrorView(
title: String,
message: String,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
val updatedOnTextLayout by rememberUpdatedState(onTextLayout)
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgCriticalSubtle)
.padding(contentPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Error(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary
)
Column(Modifier.weight(1f)) {
Text(
text = title,
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
val textMeasurer = rememberTextMeasurer()
val textContent = remember(message) {
movableContentOf(
@Composable {
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
)
}
// BoxWithConstraints is needed to be able to calculate the text layout in the current constraints, so we can pass it to the onTextLayout callback.
// However, this can't be used inside a SubComposeLayout (like the one in the text composer), so we only use it when the onTextLayout callback
// is provided.
if (updatedOnTextLayout != null) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
LaunchedEffect(message) {
updatedOnTextLayout?.invoke(textMeasurer.measure(message, overflow = TextOverflow.Visible, constraints = constraints))
}
textContent()
}
} else {
textContent()
}
}
}
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
/**
* Wrapper for the content validation state of an event.
*
* It can be used to check the overall validation state of the event, as well as the validation state of each media item in it.
*/
@Immutable
interface ContentValidationState {
/**
* A flow that emits the overall validation state of the event, which is an aggregation of the different media values.
*/
val overallStateFlow: Flow<ContentValidationValue>
/**
* Returns a flow that emits the validation state of a specific media item, identified by its URL.
*/
fun getMediaStateFlow(url: String): Flow<ContentValidationValue>
/**
* Returns the current overall validation state of the event.
*/
fun getCurrentOverallState(): ContentValidationValue
/**
* Returns the current validation state of a specific media item, identified by its URL.
*/
fun getCurrentMediaState(url: String): ContentValidationValue
/**
* Updates the validation state of a specific media item, identified by its URL, with a new [ContentValidationValue].
*/
fun update(url: String, newValue: ContentValidationValue)
}
/**
* A no-op implementation of [ContentValidationState] that always returns a fixed validation state.
* By default, this is [ContentValidationValue.Valid].
*/
@Immutable
class NoopContentValidationState(
private val initial: ContentValidationValue = ContentValidationValue.Valid,
) : ContentValidationState {
override val overallStateFlow: Flow<ContentValidationValue> = MutableStateFlow(initial)
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> = MutableStateFlow(initial)
override fun getCurrentOverallState(): ContentValidationValue = initial
override fun getCurrentMediaState(url: String): ContentValidationValue = initial
override fun update(url: String, newValue: ContentValidationValue) {}
}
/**
* A default implementation of [ContentValidationState] that tracks the validation state of media items in a mutable map.
* It calculates the overall validation state based on the individual media states.
*/
@Immutable
class DefaultContentValidationState(
private val states: MutableStateFlow<Map<String, ContentValidationValue>>,
) : ContentValidationState {
@OptIn(ExperimentalCoroutinesApi::class)
override val overallStateFlow: Flow<ContentValidationValue> = states
.mapLatest { states -> calculateOverallState(states) }
.distinctUntilChanged()
constructor() : this(states = MutableStateFlow(emptyMap()))
constructor(initial: Map<String, ContentValidationValue>) : this(states = MutableStateFlow(initial))
@OptIn(ExperimentalCoroutinesApi::class)
override fun getMediaStateFlow(url: String): Flow<ContentValidationValue> {
return states.map { states ->
states[url] ?: ContentValidationValue.Unknown
}.distinctUntilChanged()
}
override fun getCurrentOverallState(): ContentValidationValue {
return calculateOverallState(states.value)
}
override fun getCurrentMediaState(url: String): ContentValidationValue {
return states.value[url] ?: ContentValidationValue.Unknown
}
override fun update(url: String, newValue: ContentValidationValue) {
states.update { current ->
if (current[url]?.isValidated() == true && newValue.isLoading()) {
// If the current state is already validated and the new value is loading, we keep the current validated state
current
} else {
// Otherwise, we update the state for the given URL with the new value
current + (url to newValue)
}
}
}
}
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
return when {
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
states.values.all { it is ContentValidationValue.Valid } -> ContentValidationValue.Valid
else -> ContentValidationValue.Unknown
}
}
/**
* Represents the validation state of a media item or an event.
*/
@Immutable
sealed interface ContentValidationValue {
/** Indicates that the content is valid. */
data object Valid : ContentValidationValue
/** Indicates that the content is invalid/unsafe. */
data object Invalid : ContentValidationValue
/** Indicates that the content is currently being validated. */
data object Loading : ContentValidationValue
/** Indicates that an unrecoverable error occurred during validation, and it should not be retried. */
data class UnrecoverableError(val error: Throwable) : ContentValidationValue
/** Indicates that the validation state is unknown and should be checked. Recoverable errors are also represented with this. */
data object Unknown : ContentValidationValue
/**
* Returns true if the validation state is either [Valid], [Invalid], or [UnrecoverableError], meaning that the validation process has completed
* and a definitive result is available.
*/
fun isValidated(): Boolean = when (this) {
is Valid -> true
is Invalid -> true
is Loading -> false
is UnrecoverableError -> true
is Unknown -> false
}
/** Returns true if the validation state is [Valid]. */
fun isValid(): Boolean = this is Valid
/** Returns true if the validation state is [Invalid]. */
fun isInvalid(): Boolean = this is Invalid
/** Returns true if the validation state is [Loading]. */
fun isLoading(): Boolean = this is Loading
/** Returns true if the validation state is [UnrecoverableError]. */
fun hasUnrecoverableError(): Boolean = this is UnrecoverableError
/** Returns true if the validation state is either [Invalid] or [UnrecoverableError]. */
fun hasError(): Boolean = this is Invalid || this is UnrecoverableError
}
/**
* Collects the overall validation state of the [ContentValidationState] as a [State] in a Composable context.
*/
@Composable
fun ContentValidationState.collectOverallState(): State<ContentValidationValue> {
return produceState(getCurrentOverallState()) {
overallStateFlow.collect { value = it }
}
}
/**
* Collects the validation state of a specific media item, identified by its URL, as a [State] in a Composable context.
*/
@Composable
fun ContentValidationState.collectMediaState(url: String?): State<ContentValidationValue> {
if (url == null) {
return remember { mutableStateOf(ContentValidationValue.Unknown) }
}
return produceState(getCurrentMediaState(url), key1 = url) {
getMediaStateFlow(url).collect { value = it }
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import io.element.android.libraries.matrix.api.core.EventId
/**
* A cache that holds the validation state of event contents, based on their [EventId].
*/
interface EventContentValidationCache {
/**
* Returns the [ContentValidationState] for the given [eventId]. If none exists, it creates a new one with [ContentValidationValue.Unknown].
*/
operator fun get(eventId: EventId): ContentValidationState
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import io.element.android.libraries.matrix.api.core.EventId
/**
* In-memory variant of [EventContentValidationCache] that holds the validation state of event contents, based on their [EventId].
*
* Note: this is intended to be used in tests.
*/
class InMemoryEventContentValidationCache(
initial: Map<EventId, ContentValidationState> = emptyMap(),
) : EventContentValidationCache {
private val cache = initial.toMutableMap()
override operator fun get(eventId: EventId): ContentValidationState {
return cache[eventId] ?: DefaultContentValidationState()
}
fun put(eventId: EventId, state: ContentValidationState) {
cache[eventId] = state
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view to display when the media content is invalid or dangerous.
*/
@Composable
fun InvalidContentView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
ContentErrorView(
title = stringResource(CommonStrings.content_scanner_unsafe_title),
message = stringResource(CommonStrings.content_scanner_unsafe_message),
modifier = modifier,
contentPadding = contentPadding,
onTextLayout = onTextLayout,
)
}
@PreviewsDayNight
@Composable
internal fun InvalidContentViewPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InvalidContentView()
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
private val noopValue = NoopContentValidationState()
/**
* A composition local that provides an [EventContentValidationCache] instance, so we can check if a given event content has been validated or not.
*/
val LocalEventContentValidationState = staticCompositionLocalOf<EventContentValidationCache> { NoopEventContentValidationCache() }
/**
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
*/
class NoopEventContentValidationCache : EventContentValidationCache {
override operator fun get(eventId: EventId): ContentValidationState = noopValue
}
/**
* A helper to remember the validation state of an event content, based on its [EventId] and [EventContent], if known.
*/
@Composable
fun rememberEventContentValidationState(eventId: EventId?, eventContent: EventContent?): ContentValidationState {
val needsValidation = remember(eventContent) {
when (eventContent) {
is MessageContent -> eventContent.isMediaContent()
is StickerContent -> true
// If the event content is not known or not a media content, we don't need to validate it.
else -> false
}
}
return rememberEventContentValidationState(eventId, needsValidation)
}
/**
* A helper to remember the validation state of an event content, based on its [EventId] and whether it needs validation or not.
*/
@Composable
fun rememberEventContentValidationState(eventId: EventId?, needsValidation: Boolean): ContentValidationState {
if (!needsValidation) {
return noopValue
}
return rememberEventContentValidationStateInternal(eventId)
}
@Composable
private fun rememberEventContentValidationStateInternal(eventId: EventId?): ContentValidationState {
val cache = LocalEventContentValidationState.current
return remember(eventId) {
if (eventId != null) {
cache[eventId]
} else {
noopValue
}
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media.contentvalidation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view to display when the media content can't be fetched.
*/
@Composable
fun NotFoundContentView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
ContentErrorView(
title = stringResource(CommonStrings.content_scanner_not_found_title),
message = stringResource(CommonStrings.content_scanner_not_found),
modifier = modifier,
contentPadding = contentPadding,
onTextLayout = onTextLayout,
)
}
@PreviewsDayNight
@Composable
internal fun NotFoundContentViewPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NotFoundContentView()
}
}
@@ -40,6 +40,12 @@ fun InReplyToDetails.eventId() = when (this) {
is InReplyToDetails.Error -> eventId
}
fun InReplyToDetails.content() = when (this) {
is InReplyToDetails.Ready -> eventContent
is InReplyToDetails.Loading -> null
is InReplyToDetails.Error -> null
}
fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
@@ -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))
}
}
@@ -18,6 +18,7 @@ data class GalleryItemData(
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val type: Type,
val blurHash: String?,
) : Parcelable {
enum class Type {
Image,
@@ -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
}
@@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
// Maybe add avatar blurhash if available?
blurHash = null,
)
}
@@ -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,
@@ -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,
)
@@ -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,
@@ -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
}
}
@@ -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,
)
}
@@ -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)
@@ -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() -> {
@@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest {
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null,
blurHash = null,
)
)
assertThat(result.plugins).contains(callback)
@@ -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,
)
)
}
@@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest {
),
mediaSource = MediaSource("url"),
thumbnailSource = MediaSource("url_thumbnail"),
blurHash = A_BLUR_HASH,
)
),
fileItems = persistentListOf()
@@ -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(),
@@ -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,
)
@@ -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,
)
}
@@ -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(