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:
parent
ba564bbe9c
commit
1e0891bb68
234 changed files with 489 additions and 187 deletions
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) {
|
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 TimelineItemStickerContent,
|
||||||
is TimelineItemVideoContent,
|
|
||||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||||
is TimelineItemPollContent -> TimestampPosition.Below
|
is TimelineItemPollContent -> TimestampPosition.Below
|
||||||
else -> TimestampPosition.Default
|
else -> TimestampPosition.Default
|
||||||
|
|
@ -723,10 +723,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
Column {
|
Column {
|
||||||
sequenceOf(false, true).forEach {
|
sequenceOf(false, true).forEach { isMine ->
|
||||||
ATimelineItemEventRow(
|
ATimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
isMine = it,
|
isMine = isMine,
|
||||||
content = aTimelineItemTextContent().copy(
|
content = aTimelineItemTextContent().copy(
|
||||||
body = "A long text which will be displayed on several lines and" +
|
body = "A long text which will be displayed on several lines and" +
|
||||||
" hopefully can be manually adjusted to test different behaviors."
|
" hopefully can be manually adjusted to test different behaviors."
|
||||||
|
|
@ -736,7 +736,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
)
|
)
|
||||||
ATimelineItemEventRow(
|
ATimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
isMine = it,
|
isMine = isMine,
|
||||||
content = aTimelineItemImageContent().copy(
|
content = aTimelineItemImageContent().copy(
|
||||||
aspectRatio = 2.5f
|
aspectRatio = 2.5f
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||||
),
|
),
|
||||||
aMessageContent(
|
aMessageContent(
|
||||||
body = "Video",
|
body = "Video",
|
||||||
type = VideoMessageType("Video", MediaSource("url"), null),
|
type = VideoMessageType("Video", null, null, MediaSource("url"), null),
|
||||||
),
|
),
|
||||||
aMessageContent(
|
aMessageContent(
|
||||||
body = "Audio",
|
body = "Audio",
|
||||||
|
|
@ -113,7 +113,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||||
),
|
),
|
||||||
aMessageContent(
|
aMessageContent(
|
||||||
body = "Image",
|
body = "Image",
|
||||||
type = ImageMessageType("Image", MediaSource("url"), null),
|
type = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||||
),
|
),
|
||||||
aMessageContent(
|
aMessageContent(
|
||||||
body = "Sticker",
|
body = "Sticker",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
private const val MIN_HEIGHT_IN_DP = 100
|
const val MIN_HEIGHT_IN_DP = 100
|
||||||
private const val MAX_HEIGHT_IN_DP = 360
|
const val MAX_HEIGHT_IN_DP = 360
|
||||||
private const val DEFAULT_ASPECT_RATIO = 1.33f
|
private const val DEFAULT_ASPECT_RATIO = 1.33f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ fun TimelineItemEventContentView(
|
||||||
)
|
)
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
content = content,
|
content = content,
|
||||||
|
onContentLayoutChanged = onContentLayoutChanged,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||||
|
|
@ -85,6 +86,7 @@ fun TimelineItemEventContentView(
|
||||||
)
|
)
|
||||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||||
content = content,
|
content = content,
|
||||||
|
onContentLayoutChanged = onContentLayoutChanged,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
is TimelineItemFileContent -> TimelineItemFileView(
|
is TimelineItemFileContent -> TimelineItemFileView(
|
||||||
|
|
|
||||||
|
|
@ -16,39 +16,134 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
|
import android.text.SpannedString
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.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.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.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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import 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.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||||
import io.element.android.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.matrix.ui.media.MediaRequestData
|
||||||
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemImageView(
|
fun TimelineItemImageView(
|
||||||
content: TimelineItemImageContent,
|
content: TimelineItemImageContent,
|
||||||
|
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val description = stringResource(CommonStrings.common_image)
|
val description = stringResource(CommonStrings.common_image)
|
||||||
TimelineItemAspectRatioBox(
|
Column(
|
||||||
aspectRatio = content.aspectRatio,
|
|
||||||
modifier = modifier.semantics { contentDescription = description },
|
modifier = modifier.semantics { contentDescription = description },
|
||||||
) {
|
) {
|
||||||
BlurHashAsyncImage(
|
val containerModifier = if (content.showCaption) {
|
||||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
Modifier
|
||||||
blurHash = content.blurhash,
|
.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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
|
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 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.TimelineItemStickerContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
||||||
import io.element.android.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
|
||||||
|
|
@ -16,54 +16,124 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
|
import android.text.SpannedString
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import 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.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||||
import io.element.android.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.modifiers.roundedBackground
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemVideoView(
|
fun TimelineItemVideoView(
|
||||||
content: TimelineItemVideoContent,
|
content: TimelineItemVideoContent,
|
||||||
|
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val description = stringResource(CommonStrings.common_image)
|
val description = stringResource(CommonStrings.common_image)
|
||||||
TimelineItemAspectRatioBox(
|
Column(
|
||||||
aspectRatio = content.aspectRatio,
|
modifier = modifier.semantics { contentDescription = description }
|
||||||
modifier = modifier.semantics { contentDescription = description },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
BlurHashAsyncImage(
|
val containerModifier = if (content.showCaption) {
|
||||||
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp))
|
||||||
blurHash = content.blurHash,
|
} else {
|
||||||
contentScale = ContentScale.Crop,
|
Modifier
|
||||||
)
|
}
|
||||||
Box(
|
TimelineItemAspectRatioBox(
|
||||||
modifier = Modifier.roundedBackground(),
|
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
|
||||||
|
aspectRatio = content.aspectRatio,
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
var isLoaded by remember { mutableStateOf(false) }
|
||||||
Icons.Default.PlayArrow,
|
AsyncImage(
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
modifier = Modifier
|
||||||
colorFilter = ColorFilter.tint(Color.White),
|
.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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
|
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)
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
TimelineItemImageContent(
|
TimelineItemImageContent(
|
||||||
body = messageType.body.trimEnd(),
|
body = messageType.body.trimEnd(),
|
||||||
|
formatted = messageType.formatted,
|
||||||
|
filename = messageType.filename,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -91,7 +93,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
height = messageType.info?.height?.toInt(),
|
height = messageType.info?.height?.toInt(),
|
||||||
aspectRatio = aspectRatio,
|
aspectRatio = aspectRatio,
|
||||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is StickerMessageType -> {
|
is StickerMessageType -> {
|
||||||
|
|
@ -132,6 +134,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
TimelineItemVideoContent(
|
TimelineItemVideoContent(
|
||||||
body = messageType.body.trimEnd(),
|
body = messageType.body.trimEnd(),
|
||||||
|
formatted = messageType.formatted,
|
||||||
|
filename = messageType.filename,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
videoSource = messageType.source,
|
videoSource = messageType.source,
|
||||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
|
@ -141,7 +145,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
blurHash = messageType.info?.blurhash,
|
blurHash = messageType.info?.blurhash,
|
||||||
aspectRatio = aspectRatio,
|
aspectRatio = aspectRatio,
|
||||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is AudioMessageType -> {
|
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.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||||
|
|
||||||
data class TimelineItemImageContent(
|
data class TimelineItemImageContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
val formatted: FormattedBody?,
|
||||||
|
val filename: String?,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val formattedFileSize: String,
|
val formattedFileSize: String,
|
||||||
|
|
@ -33,6 +36,9 @@ data class TimelineItemImageContent(
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
override val type: String = "TimelineItemImageContent"
|
override val type: String = "TimelineItemImageContent"
|
||||||
|
|
||||||
|
val showCaption = filename != null && filename != body
|
||||||
|
val caption = if (showCaption) body else ""
|
||||||
|
|
||||||
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
||||||
mediaSource
|
mediaSource
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
|
||||||
|
|
||||||
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||||
body = "a body",
|
body = "a body",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
mimeType = MimeTypes.IMAGE_JPEG,
|
mimeType = MimeTypes.IMAGE_JPEG,
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
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.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemVideoContent(
|
data class TimelineItemVideoContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
val formatted: FormattedBody?,
|
||||||
|
val filename: String?,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val videoSource: MediaSource,
|
val videoSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
|
|
@ -33,4 +36,7 @@ data class TimelineItemVideoContent(
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
override val type: String = "TimelineItemImageContent"
|
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(
|
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||||
body = "Video.mp4",
|
body = "Video.mp4",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = A_BLUR_HASH,
|
blurHash = A_BLUR_HASH,
|
||||||
aspectRatio = 0.5f,
|
aspectRatio = 0.5f,
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@ class MessagesPresenterTest {
|
||||||
val mediaMessage = aMessageEvent(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemImageContent(
|
content = TimelineItemImageContent(
|
||||||
body = "image.jpg",
|
body = "image.jpg",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
mediaSource = MediaSource(AN_AVATAR_URL),
|
mediaSource = MediaSource(AN_AVATAR_URL),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
mimeType = MimeTypes.Jpeg,
|
mimeType = MimeTypes.Jpeg,
|
||||||
|
|
@ -300,6 +302,8 @@ class MessagesPresenterTest {
|
||||||
val mediaMessage = aMessageEvent(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemVideoContent(
|
content = TimelineItemVideoContent(
|
||||||
body = "video.mp4",
|
body = "video.mp4",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
duration = 10.milliseconds,
|
duration = 10.milliseconds,
|
||||||
videoSource = MediaSource(AN_AVATAR_URL),
|
videoSource = MediaSource(AN_AVATAR_URL),
|
||||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||||
|
|
|
||||||
|
|
@ -227,12 +227,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
fun `test create VideoMessageType`() = runTest {
|
fun `test create VideoMessageType`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = VideoMessageType("body", MediaSource("url"), null)),
|
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
|
||||||
senderDisplayName = "Bob",
|
senderDisplayName = "Bob",
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
duration = Duration.ZERO,
|
duration = Duration.ZERO,
|
||||||
videoSource = MediaSource(url = "url", json = null),
|
videoSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -253,7 +255,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(
|
content = createMessageContent(
|
||||||
type = VideoMessageType(
|
type = VideoMessageType(
|
||||||
body = "body.mp4",
|
body = "body.mp4 caption",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||||
|
filename = "body.mp4",
|
||||||
source = MediaSource("url"),
|
source = MediaSource("url"),
|
||||||
info = VideoInfo(
|
info = VideoInfo(
|
||||||
duration = 1.minutes,
|
duration = 1.minutes,
|
||||||
|
|
@ -276,7 +280,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
body = "body.mp4",
|
body = "body.mp4 caption",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||||
|
filename = "body.mp4",
|
||||||
duration = 1.minutes,
|
duration = 1.minutes,
|
||||||
videoSource = MediaSource(url = "url", json = null),
|
videoSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
|
|
@ -420,12 +426,14 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
fun `test create ImageMessageType`() = runTest {
|
fun `test create ImageMessageType`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = ImageMessageType("body", MediaSource("url"), null)),
|
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
|
||||||
senderDisplayName = "Bob",
|
senderDisplayName = "Bob",
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
formattedFileSize = "0 Bytes",
|
formattedFileSize = "0 Bytes",
|
||||||
|
|
@ -470,7 +478,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(
|
content = createMessageContent(
|
||||||
type = ImageMessageType(
|
type = ImageMessageType(
|
||||||
body = "body.jpg",
|
body = "body.jpg caption",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||||
|
filename = "body.jpg",
|
||||||
source = MediaSource("url"),
|
source = MediaSource("url"),
|
||||||
info = ImageInfo(
|
info = ImageInfo(
|
||||||
height = 10L,
|
height = 10L,
|
||||||
|
|
@ -492,7 +502,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
body = "body.jpg",
|
body = "body.jpg caption",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||||
|
filename = "body.jpg",
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
formattedFileSize = "888 Bytes",
|
formattedFileSize = "888 Bytes",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ class InReplyToMetadataKtTest {
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = ImageMessageType(
|
messageType = ImageMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
source = aMediaSource(),
|
source = aMediaSource(),
|
||||||
info = anImageInfo(),
|
info = anImageInfo(),
|
||||||
)
|
)
|
||||||
|
|
@ -137,6 +139,8 @@ class InReplyToMetadataKtTest {
|
||||||
eventContent = aMessageContent(
|
eventContent = aMessageContent(
|
||||||
messageType = VideoMessageType(
|
messageType = VideoMessageType(
|
||||||
body = "body",
|
body = "body",
|
||||||
|
formatted = null,
|
||||||
|
filename = null,
|
||||||
source = aMediaSource(),
|
source = aMediaSource(),
|
||||||
info = aVideoInfo(),
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,27 +14,22 @@
|
||||||
* limitations under the License.
|
* 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.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.vanniktech.blurhash.BlurHash
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BlurHashAsyncImage(
|
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(
|
val sharedContentMessagesTypes = arrayOf(
|
||||||
TextMessageType(body, null),
|
TextMessageType(body, null),
|
||||||
VideoMessageType(body, MediaSource("url"), null),
|
VideoMessageType(body, null, null, MediaSource("url"), null),
|
||||||
AudioMessageType(body, MediaSource("url"), null),
|
AudioMessageType(body, MediaSource("url"), null),
|
||||||
VoiceMessageType(body, MediaSource("url"), null, null),
|
VoiceMessageType(body, MediaSource("url"), null, null),
|
||||||
ImageMessageType(body, MediaSource("url"), null),
|
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||||
StickerMessageType(body, MediaSource("url"), null),
|
StickerMessageType(body, MediaSource("url"), null),
|
||||||
FileMessageType(body, MediaSource("url"), null),
|
FileMessageType(body, MediaSource("url"), null),
|
||||||
LocationMessageType(body, "geo:1,2", null),
|
LocationMessageType(body, "geo:1,2", null),
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ data class EmoteMessageType(
|
||||||
|
|
||||||
data class ImageMessageType(
|
data class ImageMessageType(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
val formatted: FormattedBody?,
|
||||||
|
val filename: String?,
|
||||||
val source: MediaSource,
|
val source: MediaSource,
|
||||||
val info: ImageInfo?
|
val info: ImageInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
@ -65,6 +67,8 @@ data class VoiceMessageType(
|
||||||
|
|
||||||
data class VideoMessageType(
|
data class VideoMessageType(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
val formatted: FormattedBody?,
|
||||||
|
val filename: String?,
|
||||||
val source: MediaSource,
|
val source: MediaSource,
|
||||||
val info: VideoInfo?
|
val info: VideoInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ class EventMessageMapper {
|
||||||
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||||
}
|
}
|
||||||
is RustMessageType.Image -> {
|
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 -> {
|
is RustMessageType.Notice -> {
|
||||||
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
||||||
|
|
@ -110,7 +110,7 @@ class EventMessageMapper {
|
||||||
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
||||||
}
|
}
|
||||||
is RustMessageType.Video -> {
|
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 -> {
|
is RustMessageType.Location -> {
|
||||||
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
|
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.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ class NotifiableEventResolverTest {
|
||||||
createNotificationData(
|
createNotificationData(
|
||||||
content = NotificationContent.MessageLike.RoomMessage(
|
content = NotificationContent.MessageLike.RoomMessage(
|
||||||
senderId = A_USER_ID_2,
|
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(
|
createNotificationData(
|
||||||
content = NotificationContent.MessageLike.RoomMessage(
|
content = NotificationContent.MessageLike.RoomMessage(
|
||||||
senderId = A_USER_ID_2,
|
senderId = A_USER_ID_2,
|
||||||
messageType = ImageMessageType("Image", MediaSource("url"), null),
|
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9da6e0e90c73d447145dd71a7dd1cd2f6c60cc1a85bb57427d522fb793263ad1
|
||||||
|
size 137016
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9878ddbb3e3ac9a9e997002487272edd73efc8beb329cd9ad6e4f12f388fcb5a
|
||||||
|
size 135425
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:301b6cc63cc5e075aa70e0d4d67da5a3fd96049f834f5d04665e905de6860d89
|
oid sha256:77cdd18c2c42c406ba338ad56d133a9f220f6de00553b61be6edee1fb933dd5e
|
||||||
size 123525
|
size 118753
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
|
oid sha256:cd0025b120e0d90a9770524cf2c4ce7b7966338083a8b63b6db9285dd8949c8d
|
||||||
size 185325
|
size 180290
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
|
oid sha256:f9660b5e2034920207c53c2b02aea5f85f9278393208e5bd21b69ac6060a486c
|
||||||
size 137137
|
size 130411
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d03c9055f17ecba10787da59bc7d598b9b397c52f70f930a4603c58bb26b2d60
|
oid sha256:ed3e7817151d518a353834abad6469ecbd14376988712282a3f5f04396de6118
|
||||||
size 123995
|
size 119553
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:df1bcc21fd0d3aea40bf70f8f6bb29b90fac461e5038ed06e1f52486874e0d68
|
oid sha256:63ee2e5cf1f537bf99bd3b2145c3a8e0586257638772b99738d8ba39e39f00a5
|
||||||
size 185273
|
size 179578
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:486097573705148658e334d7543bd78df6430a742dfb984b5578ecd6cf81d5ff
|
oid sha256:adeb027966ee73299589716934ddf1cf39c158c06c1ba5998aff06979c355d63
|
||||||
size 136445
|
size 130106
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:f4fe06708cd1f1e49254779bce07f6751792a94fa9065882385efad1ac8663c2
|
|
||||||
size 124159
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
|
|
||||||
size 186118
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
|
|
||||||
size 137535
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e6f5154be0e92e6c8f0d2972eb32c9b80690936d4b1c7f011ad31e94da2c3f90
|
||||||
|
size 119153
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:117e853cd3c5a02de954320e89a930c2b805d90f88e31e3fde63a42fc2b95e15
|
||||||
|
size 180804
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:83280e9f22fcae0f4777eb9cf5e77cf0e929761001665161aacc52b2f0387259
|
||||||
|
size 130882
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b1c09f5b8c68f493a04541a7ce77ca33d98c41e56c020e355dfc4419406592ae
|
|
||||||
size 124700
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e99f07b952b52536d0f7b564e36a98b27ecfb44c3419cf0f41a397cfc48bbfdf
|
|
||||||
size 186016
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ae50c359db9d0e22de60b49fadb2a6b2ea514414a0d27c42e5050e2b88454e0e
|
|
||||||
size 136838
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5e82405b9a2ce4605d0aa2cbb263dde7e8d4d92d2c8bdab43c045cc179b8377b
|
||||||
|
size 119796
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d3ce35907e27728ba0f476a4b61409ca47276b34987bd5131eca383268531950
|
||||||
|
size 180168
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue