Implement MSC2530 (#2570)
* Implement MSC2530 * Some layout improvements for images and videos with captions * Update screenshots * Replace `it` in several previews with `isMine` --------- Signed-off-by: Marco Antonio Alvarez <surakin@gmail.com> Co-authored-by: Marco Antonio Alvarez <surakin@gmail.com> Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
committed by
GitHub
parent
ba564bbe9c
commit
1e0891bb68
1
changelog.d/2521.feature
Normal file
1
changelog.d/2521.feature
Normal file
@@ -0,0 +1 @@
|
||||
Implement MSC2530 (Body field as media caption)
|
||||
@@ -615,9 +615,9 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
|
||||
val timestampPosition = when (event.content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
@@ -723,10 +723,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = it,
|
||||
isMine = isMine,
|
||||
content = aTimelineItemTextContent().copy(
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
@@ -736,7 +736,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = it,
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
|
||||
@@ -101,7 +101,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Video",
|
||||
type = VideoMessageType("Video", MediaSource("url"), null),
|
||||
type = VideoMessageType("Video", null, null, MediaSource("url"), null),
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Audio",
|
||||
@@ -113,7 +113,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Image",
|
||||
type = ImageMessageType("Image", MediaSource("url"), null),
|
||||
type = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Sticker",
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val MIN_HEIGHT_IN_DP = 100
|
||||
private const val MAX_HEIGHT_IN_DP = 360
|
||||
const val MIN_HEIGHT_IN_DP = 100
|
||||
const val MAX_HEIGHT_IN_DP = 360
|
||||
private const val DEFAULT_ASPECT_RATIO = 1.33f
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -77,6 +77,7 @@ fun TimelineItemEventContentView(
|
||||
)
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||
@@ -85,6 +86,7 @@ fun TimelineItemEventContentView(
|
||||
)
|
||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||
content = content,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
|
||||
@@ -16,39 +16,134 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
blurHash = content.blurhash,
|
||||
)
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
|
||||
aspectRatio = content.aspectRatio,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
|
||||
TimelineItemImageView(content)
|
||||
TimelineItemImageView(content, {})
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
filename = "image.jpg",
|
||||
body = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
|
||||
@@ -16,54 +16,124 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVideoView(
|
||||
content: TimelineItemVideoContent,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
contentAlignment = Alignment.Center,
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description }
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
blurHash = content.blurHash,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
|
||||
aspectRatio = content.aspectRatio,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,5 +141,25 @@ fun TimelineItemVideoView(
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
|
||||
TimelineItemVideoView(content)
|
||||
TimelineItemVideoView(content, {})
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
body = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||
TimelineItemImageContent(
|
||||
body = messageType.body.trimEnd(),
|
||||
formatted = messageType.formatted,
|
||||
filename = messageType.filename,
|
||||
mediaSource = messageType.source,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
@@ -91,7 +93,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
height = messageType.info?.height?.toInt(),
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty()
|
||||
)
|
||||
}
|
||||
is StickerMessageType -> {
|
||||
@@ -132,6 +134,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||
TimelineItemVideoContent(
|
||||
body = messageType.body.trimEnd(),
|
||||
formatted = messageType.formatted,
|
||||
filename = messageType.filename,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
videoSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
@@ -141,7 +145,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty(),
|
||||
)
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
|
||||
@@ -18,9 +18,12 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
|
||||
data class TimelineItemImageContent(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val formattedFileSize: String,
|
||||
@@ -33,6 +36,9 @@ data class TimelineItemImageContent(
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = filename != null && filename != body
|
||||
val caption = if (showCaption) body else ""
|
||||
|
||||
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
||||
mediaSource
|
||||
} else {
|
||||
|
||||
@@ -32,6 +32,8 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
|
||||
|
||||
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||
body = "a body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
mimeType = MimeTypes.IMAGE_JPEG,
|
||||
|
||||
@@ -17,10 +17,13 @@
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemVideoContent(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val duration: Duration,
|
||||
val videoSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
@@ -33,4 +36,7 @@ data class TimelineItemVideoContent(
|
||||
val fileExtension: String,
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = filename != null && filename != body
|
||||
val caption = if (showCaption) body else ""
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||
|
||||
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||
body = "Video.mp4",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
thumbnailSource = null,
|
||||
blurHash = A_BLUR_HASH,
|
||||
aspectRatio = 0.5f,
|
||||
|
||||
@@ -270,6 +270,8 @@ class MessagesPresenterTest {
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
body = "image.jpg",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = null,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
@@ -300,6 +302,8 @@ class MessagesPresenterTest {
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
body = "video.mp4",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
duration = 10.milliseconds,
|
||||
videoSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||
|
||||
@@ -227,12 +227,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||
fun `test create VideoMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VideoMessageType("body", MediaSource("url"), null)),
|
||||
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
duration = Duration.ZERO,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = null,
|
||||
@@ -253,7 +255,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = VideoMessageType(
|
||||
body = "body.mp4",
|
||||
body = "body.mp4 caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.mp4",
|
||||
source = MediaSource("url"),
|
||||
info = VideoInfo(
|
||||
duration = 1.minutes,
|
||||
@@ -276,7 +280,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body.mp4",
|
||||
body = "body.mp4 caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.mp4",
|
||||
duration = 1.minutes,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
@@ -420,12 +426,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||
fun `test create ImageMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = ImageMessageType("body", MediaSource("url"), null)),
|
||||
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = null,
|
||||
formattedFileSize = "0 Bytes",
|
||||
@@ -470,7 +478,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = ImageMessageType(
|
||||
body = "body.jpg",
|
||||
body = "body.jpg caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.jpg",
|
||||
source = MediaSource("url"),
|
||||
info = ImageInfo(
|
||||
height = 10L,
|
||||
@@ -492,7 +502,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
body = "body.jpg",
|
||||
body = "body.jpg caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.jpg",
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
formattedFileSize = "888 Bytes",
|
||||
|
||||
@@ -83,6 +83,8 @@ class InReplyToMetadataKtTest {
|
||||
eventContent = aMessageContent(
|
||||
messageType = ImageMessageType(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
source = aMediaSource(),
|
||||
info = anImageInfo(),
|
||||
)
|
||||
@@ -137,6 +139,8 @@ class InReplyToMetadataKtTest {
|
||||
eventContent = aMessageContent(
|
||||
messageType = VideoMessageType(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
source = aMediaSource(),
|
||||
info = aVideoInfo(),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,27 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components
|
||||
package io.element.android.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
|
||||
@Composable
|
||||
fun BlurHashAsyncImage(
|
||||
@@ -69,31 +64,3 @@ fun BlurHashAsyncImage(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlurHashImage(
|
||||
blurHash: String?,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
if (blurHash == null) return
|
||||
val bitmapState = remember(blurHash) {
|
||||
mutableStateOf(
|
||||
// Build a small blurhash image so that it's fast
|
||||
BlurHash.decode(blurHash, 10, 10)
|
||||
)
|
||||
}
|
||||
DisposableEffect(blurHash) {
|
||||
onDispose {
|
||||
bitmapState.value?.recycle()
|
||||
}
|
||||
}
|
||||
bitmapState.value?.let { bitmap ->
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentScale = contentScale,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
fun Modifier.blurHashBackground(blurHash: String?, alpha: Float = 1f) = this.composed {
|
||||
val blurHashBitmap = rememberBlurHashImage(blurHash)
|
||||
if (blurHashBitmap != null) {
|
||||
Modifier.drawBehind {
|
||||
drawImage(blurHashBitmap, dstSize = IntSize(size.width.toInt(), size.height.toInt()), alpha = alpha)
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
|
||||
@Suppress("ModifierMissing")
|
||||
@Composable
|
||||
fun BlurHashImage(
|
||||
blurHash: String?,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
if (blurHash == null) return
|
||||
val blurHashImage = rememberBlurHashImage(blurHash)
|
||||
blurHashImage?.let { bitmap ->
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bitmap = bitmap,
|
||||
contentScale = contentScale,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBlurHashImage(blurHash: String?): ImageBitmap? {
|
||||
return if (LocalInspectionMode.current) {
|
||||
blurHash?.let { BlurHash.decode(it, 10, 10)?.asImageBitmap() }
|
||||
} else {
|
||||
produceState<ImageBitmap?>(initialValue = null, blurHash) {
|
||||
blurHash?.let { value = BlurHash.decode(it, 10, 10)?.asImageBitmap() }
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -161,10 +161,10 @@ class DefaultRoomLastMessageFormatterTest {
|
||||
|
||||
val sharedContentMessagesTypes = arrayOf(
|
||||
TextMessageType(body, null),
|
||||
VideoMessageType(body, MediaSource("url"), null),
|
||||
VideoMessageType(body, null, null, MediaSource("url"), null),
|
||||
AudioMessageType(body, MediaSource("url"), null),
|
||||
VoiceMessageType(body, MediaSource("url"), null, null),
|
||||
ImageMessageType(body, MediaSource("url"), null),
|
||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||
StickerMessageType(body, MediaSource("url"), null),
|
||||
FileMessageType(body, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null),
|
||||
|
||||
@@ -34,6 +34,8 @@ data class EmoteMessageType(
|
||||
|
||||
data class ImageMessageType(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val source: MediaSource,
|
||||
val info: ImageInfo?
|
||||
) : MessageType
|
||||
@@ -65,6 +67,8 @@ data class VoiceMessageType(
|
||||
|
||||
data class VideoMessageType(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val source: MediaSource,
|
||||
val info: VideoInfo?
|
||||
) : MessageType
|
||||
|
||||
@@ -98,7 +98,7 @@ class EventMessageMapper {
|
||||
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Image -> {
|
||||
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
ImageMessageType(type.content.body, type.content.formatted?.map(), type.content.filename, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Notice -> {
|
||||
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
||||
@@ -110,7 +110,7 @@ class EventMessageMapper {
|
||||
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
||||
}
|
||||
is RustMessageType.Video -> {
|
||||
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
VideoMessageType(type.content.body, type.content.formatted?.map(), type.content.filename, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Location -> {
|
||||
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
|
||||
|
||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.components.PinIcon
|
||||
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
|
||||
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
|
||||
|
||||
@@ -190,7 +190,7 @@ class NotifiableEventResolverTest {
|
||||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = VideoMessageType(body = "Video", MediaSource("url"), null)
|
||||
messageType = VideoMessageType(body = "Video", null, null, MediaSource("url"), null)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -224,7 +224,7 @@ class NotifiableEventResolverTest {
|
||||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = ImageMessageType("Image", MediaSource("url"), null),
|
||||
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user