Merge branch 'develop' into feature/fga/room_directory
This commit is contained in:
commit
3d071f0765
264 changed files with 944 additions and 201 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)
|
||||||
1
changelog.d/2574.misc
Normal file
1
changelog.d/2574.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components.
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
|
||||||
activeFilter = PollHistoryFilter.PAST,
|
activeFilter = PollHistoryFilter.PAST,
|
||||||
currentItems = emptyList(),
|
currentItems = emptyList(),
|
||||||
),
|
),
|
||||||
|
aPollHistoryState(
|
||||||
|
activeFilter = PollHistoryFilter.PAST,
|
||||||
|
currentItems = emptyList(),
|
||||||
|
hasMoreToLoad = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ private fun PollHistoryList(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
|
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
|
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
|
||||||
stringResource(R.string.screen_polls_history_empty_past)
|
stringResource(R.string.screen_polls_history_empty_past)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ signing.element.nightly.keyPassword=Secret
|
||||||
|
|
||||||
# Customise the Lint version to use a more recent version than the one bundled with AGP
|
# Customise the Lint version to use a more recent version than the one bundled with AGP
|
||||||
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
|
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
|
||||||
android.experimental.lint.version=8.3.0-alpha12
|
android.experimental.lint.version=8.4.0-alpha13
|
||||||
|
|
||||||
# Enable test fixture for all modules by default
|
# Enable test fixture for all modules by default
|
||||||
android.experimental.enableTestFixtures=true
|
android.experimental.enableTestFixtures=true
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
[versions]
|
[versions]
|
||||||
# Project
|
# Project
|
||||||
android_gradle_plugin = "8.2.2"
|
android_gradle_plugin = "8.3.1"
|
||||||
kotlin = "1.9.22"
|
kotlin = "1.9.23"
|
||||||
ksp = "1.9.22-1.0.17"
|
ksp = "1.9.23-1.0.19"
|
||||||
firebaseAppDistribution = "4.2.0"
|
firebaseAppDistribution = "4.2.0"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
|
|
@ -18,8 +18,8 @@ activity = "1.8.2"
|
||||||
media3 = "1.3.0"
|
media3 = "1.3.0"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2024.02.02"
|
compose_bom = "2024.03.00"
|
||||||
composecompiler = "1.5.10"
|
composecompiler = "1.5.11"
|
||||||
|
|
||||||
# Coroutines
|
# Coroutines
|
||||||
coroutines = "1.8.0"
|
coroutines = "1.8.0"
|
||||||
|
|
@ -38,7 +38,7 @@ serialization_json = "1.6.3"
|
||||||
showkase = "1.0.2"
|
showkase = "1.0.2"
|
||||||
appyx = "1.4.0"
|
appyx = "1.4.0"
|
||||||
sqldelight = "2.0.1"
|
sqldelight = "2.0.1"
|
||||||
wysiwyg = "2.33.0"
|
wysiwyg = "2.34.0"
|
||||||
telephoto = "0.8.0"
|
telephoto = "0.8.0"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
|
|
@ -153,7 +153,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
|
||||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||||
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.1"
|
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.1"
|
||||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.10"
|
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.11"
|
||||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.bigCheckmarkBorderColor
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compound component that displays a big checkmark centered in a rounded square.
|
||||||
|
*
|
||||||
|
* @param modifier the modifier to apply to this layout
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BigCheckmark(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.size(120.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
border = BorderStroke(1.dp, ElementTheme.colors.bigCheckmarkBorderColor),
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = ElementTheme.colors.iconSuccessPrimary,
|
||||||
|
imageVector = CompoundIcons.CheckCircleSolid(),
|
||||||
|
contentDescription = stringResource(CommonStrings.common_success)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun BigCheckmarkPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
BigCheckmark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CatchingPokemon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
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.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.bigIconDefaultBackgroundColor
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compound component that display a big icon centered in a rounded square.
|
||||||
|
*/
|
||||||
|
object BigIcon {
|
||||||
|
/**
|
||||||
|
* The style of the [BigIcon].
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
sealed interface Style {
|
||||||
|
/**
|
||||||
|
* The default style.
|
||||||
|
*
|
||||||
|
* @param vectorIcon the [ImageVector] to display
|
||||||
|
* @param contentDescription the content description of the icon, if any. It defaults to `null`
|
||||||
|
*/
|
||||||
|
data class Default(val vectorIcon: ImageVector, val contentDescription: String? = null) : Style
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alert style with a transparent background.
|
||||||
|
*/
|
||||||
|
data object Alert : Style
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alert style with a tinted background.
|
||||||
|
*/
|
||||||
|
data object AlertSolid : Style
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A success style with a transparent background.
|
||||||
|
*/
|
||||||
|
data object Success : Style
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A success style with a tinted background.
|
||||||
|
*/
|
||||||
|
data object SuccessSolid : Style
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a [BigIcon].
|
||||||
|
*
|
||||||
|
* @param style the style of the icon
|
||||||
|
* @param modifier the modifier to apply to this layout
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
operator fun invoke(
|
||||||
|
style: Style,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backgroundColor = when (style) {
|
||||||
|
is Style.Default -> ElementTheme.colors.bigIconDefaultBackgroundColor
|
||||||
|
Style.Alert, Style.Success -> Color.Transparent
|
||||||
|
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
|
||||||
|
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
|
||||||
|
}
|
||||||
|
val icon = when (style) {
|
||||||
|
is Style.Default -> style.vectorIcon
|
||||||
|
Style.Alert, Style.AlertSolid -> CompoundIcons.Error()
|
||||||
|
Style.Success, Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
|
||||||
|
}
|
||||||
|
val contentDescription = when (style) {
|
||||||
|
is Style.Default -> style.contentDescription
|
||||||
|
Style.Alert, Style.AlertSolid -> stringResource(CommonStrings.common_error)
|
||||||
|
Style.Success, Style.SuccessSolid -> stringResource(CommonStrings.common_success)
|
||||||
|
}
|
||||||
|
val iconTint = when (style) {
|
||||||
|
is Style.Default -> ElementTheme.colors.iconSecondaryAlpha
|
||||||
|
Style.Alert, Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
|
||||||
|
Style.Success, Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = iconTint,
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun BigIconPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(10.dp)) {
|
||||||
|
val provider = BigIconStylePreviewProvider()
|
||||||
|
for (style in provider.values) {
|
||||||
|
BigIcon(style = style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class BigIconStylePreviewProvider : PreviewParameterProvider<BigIcon.Style> {
|
||||||
|
override val values: Sequence<BigIcon.Style>
|
||||||
|
get() = sequenceOf(
|
||||||
|
BigIcon.Style.Default(Icons.Filled.CatchingPokemon),
|
||||||
|
BigIcon.Style.Alert,
|
||||||
|
BigIcon.Style.AlertSolid,
|
||||||
|
BigIcon.Style.Success,
|
||||||
|
BigIcon.Style.SuccessSolid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
|
||||||
|
*
|
||||||
|
* @param title the title to display
|
||||||
|
* @param iconStyle the style of the [BigIcon] to display
|
||||||
|
* @param modifier the modifier to apply to this layout
|
||||||
|
* @param subtitle the optional subtitle to display. It defaults to `null`
|
||||||
|
* @param callToAction the optional call to action component to display. It defaults to `null`
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PageTitle(
|
||||||
|
title: AnnotatedString,
|
||||||
|
iconStyle: BigIcon.Style,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: AnnotatedString? = null,
|
||||||
|
callToAction: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
BigIcon(style = iconStyle)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = title,
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
subtitle?.let {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = it,
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callToAction?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
|
||||||
|
*
|
||||||
|
* @param title the title to display
|
||||||
|
* @param iconStyle the style of the [BigIcon] to display
|
||||||
|
* @param modifier the modifier to apply to this layout
|
||||||
|
* @param subtitle the optional subtitle to display. It defaults to `null`
|
||||||
|
* @param callToAction the optional call to action component to display. It defaults to `null`
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PageTitle(
|
||||||
|
title: String,
|
||||||
|
iconStyle: BigIcon.Style,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
callToAction: @Composable (() -> Unit)? = null,
|
||||||
|
) = PageTitle(
|
||||||
|
title = AnnotatedString(title),
|
||||||
|
iconStyle = iconStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
subtitle = subtitle?.let { AnnotatedString(it) },
|
||||||
|
callToAction = callToAction
|
||||||
|
)
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
|
||||||
|
ElementPreview {
|
||||||
|
PageTitle(
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
title = AnnotatedString("Headline"),
|
||||||
|
subtitle = AnnotatedString("Description goes here"),
|
||||||
|
iconStyle = style,
|
||||||
|
callToAction = {
|
||||||
|
TextButton(text = "Learn more", onClick = {})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TitleWithIconMinimalPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
PageTitle(
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
title = "Headline",
|
||||||
|
iconStyle = BigIcon.Style.Default(CompoundIcons.CheckCircleSolid()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,9 +19,12 @@ package io.element.android.libraries.designsystem.theme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import io.element.android.compound.annotations.CoreColorToken
|
||||||
import io.element.android.compound.previews.ColorListPreview
|
import io.element.android.compound.previews.ColorListPreview
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.SemanticColors
|
import io.element.android.compound.tokens.generated.SemanticColors
|
||||||
|
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
|
||||||
|
import io.element.android.compound.tokens.generated.internal.LightColorTokens
|
||||||
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 kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
|
@ -138,6 +141,14 @@ val SemanticColors.mentionPillBackground
|
||||||
Color(0x26f4f7fa)
|
Color(0x26f4f7fa)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(CoreColorToken::class)
|
||||||
|
val SemanticColors.bigIconDefaultBackgroundColor
|
||||||
|
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
|
||||||
|
|
||||||
|
@OptIn(CoreColorToken::class)
|
||||||
|
val SemanticColors.bigCheckmarkBorderColor
|
||||||
|
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColorAliasesPreview() = ElementPreview {
|
internal fun ColorAliasesPreview() = ElementPreview {
|
||||||
|
|
@ -155,6 +166,7 @@ internal fun ColorAliasesPreview() = ElementPreview {
|
||||||
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
|
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
|
||||||
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
||||||
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
||||||
|
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import org.gradle.api.JavaVersion
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) {
|
fun CommonExtension<*, *, *, *, *, *>.androidConfig(project: Project) {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
compileSdk = Versions.compileSdk
|
compileSdk = Versions.compileSdk
|
||||||
minSdk = Versions.minSdk
|
minSdk = Versions.minSdk
|
||||||
|
|
@ -53,7 +53,7 @@ fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CommonExtension<*, *, *, *, *>.composeConfig(libs: LibrariesForLibs) {
|
fun CommonExtension<*, *, *, *, *, *>.composeConfig(libs: LibrariesForLibs) {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
|
|
||||||
|
|
@ -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