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:
Jorge Martin Espinosa 2024-03-20 13:58:29 +01:00 committed by GitHub
parent 2777ba09a3
commit 6aa84d600e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
234 changed files with 489 additions and 187 deletions

1
changelog.d/2521.feature Normal file
View file

@ -0,0 +1 @@
Implement MSC2530 (Body field as media caption)

View file

@ -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
),

View file

@ -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",

View file

@ -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

View file

@ -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(

View file

@ -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,
),
)
}
}
}

View file

@ -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

View file

@ -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,
),
)
}
}
}

View file

@ -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 -> {

View file

@ -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 {

View file

@ -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,

View file

@ -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 ""
}

View file

@ -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,

View file

@ -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),

View file

@ -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",

View file

@ -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(),
)

View file

@ -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
)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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),

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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),
)
)
)

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9da6e0e90c73d447145dd71a7dd1cd2f6c60cc1a85bb57427d522fb793263ad1
size 137016

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9878ddbb3e3ac9a9e997002487272edd73efc8beb329cd9ad6e4f12f388fcb5a
size 135425

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:301b6cc63cc5e075aa70e0d4d67da5a3fd96049f834f5d04665e905de6860d89
size 123525
oid sha256:77cdd18c2c42c406ba338ad56d133a9f220f6de00553b61be6edee1fb933dd5e
size 118753

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
size 185325
oid sha256:cd0025b120e0d90a9770524cf2c4ce7b7966338083a8b63b6db9285dd8949c8d
size 180290

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
size 137137
oid sha256:f9660b5e2034920207c53c2b02aea5f85f9278393208e5bd21b69ac6060a486c
size 130411

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d03c9055f17ecba10787da59bc7d598b9b397c52f70f930a4603c58bb26b2d60
size 123995
oid sha256:ed3e7817151d518a353834abad6469ecbd14376988712282a3f5f04396de6118
size 119553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df1bcc21fd0d3aea40bf70f8f6bb29b90fac461e5038ed06e1f52486874e0d68
size 185273
oid sha256:63ee2e5cf1f537bf99bd3b2145c3a8e0586257638772b99738d8ba39e39f00a5
size 179578

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:486097573705148658e334d7543bd78df6430a742dfb984b5578ecd6cf81d5ff
size 136445
oid sha256:adeb027966ee73299589716934ddf1cf39c158c06c1ba5998aff06979c355d63
size 130106

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4fe06708cd1f1e49254779bce07f6751792a94fa9065882385efad1ac8663c2
size 124159

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
size 186118

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
size 137535

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6f5154be0e92e6c8f0d2972eb32c9b80690936d4b1c7f011ad31e94da2c3f90
size 119153

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:117e853cd3c5a02de954320e89a930c2b805d90f88e31e3fde63a42fc2b95e15
size 180804

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83280e9f22fcae0f4777eb9cf5e77cf0e929761001665161aacc52b2f0387259
size 130882

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1c09f5b8c68f493a04541a7ce77ca33d98c41e56c020e355dfc4419406592ae
size 124700

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e99f07b952b52536d0f7b564e36a98b27ecfb44c3419cf0f41a397cfc48bbfdf
size 186016

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae50c359db9d0e22de60b49fadb2a6b2ea514414a0d27c42e5050e2b88454e0e
size 136838

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e82405b9a2ce4605d0aa2cbb263dde7e8d4d92d2c8bdab43c045cc179b8377b
size 119796

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3ce35907e27728ba0f476a4b61409ca47276b34987bd5131eca383268531950
size 180168

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee7cd47dfc99e054e5c837ee09055cae1dc6b70210abd30ecdfc3fc781ee16fc
size 130576

Some files were not shown because too many files have changed in this diff Show more