Map all replyTo data and add preview for loading and erorr case.

This commit is contained in:
Benoit Marty 2024-04-29 17:41:25 +02:00
parent 49dd4ad803
commit b7970b2db8
7 changed files with 138 additions and 29 deletions

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -94,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -437,7 +437,7 @@ private fun MessageEventBubbleContent(
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = spacedBy(4.dp, Alignment.Start), horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
@ -565,16 +565,30 @@ private fun MessageEventBubbleContent(
} }
val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val topPadding = if (showThreadDecoration) 0.dp else 8.dp val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent( val inReplyToModifier = Modifier
senderId = inReplyTo.senderId, .padding(top = topPadding, start = 8.dp, end = 8.dp)
senderProfile = inReplyTo.senderProfile, .clip(RoundedCornerShape(6.dp))
metadata = inReplyTo.metadata(), // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
modifier = Modifier // .clickable(enabled = true, onClick = inReplyToClick)
.padding(top = topPadding, start = 8.dp, end = 8.dp) when (inReplyTo) {
.clip(RoundedCornerShape(6.dp)) is InReplyToDetails.Ready -> {
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent ReplyToContent(
// .clickable(enabled = true, onClick = inReplyToClick) senderId = inReplyTo.senderId,
) senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(),
modifier = inReplyToModifier,
)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(
data = inReplyTo,
modifier = inReplyToModifier,
)
is InReplyToDetails.Loading ->
ReplyToLoadingContent(
modifier = inReplyToModifier,
)
}
} }
if (inReplyToDetails != null) { if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance. // Use SubComposeLayout only if necessary as it can have consequences on the performance.
@ -584,7 +598,7 @@ private fun MessageEventBubbleContent(
contentWithTimestamp() contentWithTimestamp()
} }
} else { } else {
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
threadDecoration() threadDecoration()
contentWithTimestamp() contentWithTimestamp()
} }
@ -652,6 +666,44 @@ private fun ReplyToContent(
} }
} }
@Composable
private fun ReplyToLoadingContent(
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PlaceholderAtom(width = 80.dp, height = 12.dp)
PlaceholderAtom(width = 140.dp, height = 14.dp)
}
}
}
@Composable
private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Text(
text = data.message,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable @Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) { private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) { val text = when (metadata) {

View file

@ -0,0 +1,40 @@
/*
* 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.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyOtherPreview(
@PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
)
}

View file

@ -170,7 +170,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
protected fun aInReplyToDetails( protected fun aInReplyToDetails(
eventContent: EventContent, eventContent: EventContent,
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
) = InReplyToDetails( ) = InReplyToDetails.Ready(
eventId = EventId("\$event"), eventId = EventId("\$event"),
eventContent = eventContent, eventContent = eventContent,
senderId = UserId("@Sender:domain"), senderId = UserId("@Sender:domain"),

View file

@ -27,18 +27,23 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
data class InReplyToDetails( sealed class InReplyToDetails(val eventId: EventId) {
val eventId: EventId, class Ready(
val senderId: UserId, eventId: EventId,
val senderProfile: ProfileTimelineDetails, val senderId: UserId,
val eventContent: EventContent?, val senderProfile: ProfileTimelineDetails,
val textContent: String?, val eventContent: EventContent?,
) val textContent: String?,
) : InReplyToDetails(eventId)
class Loading(eventId: EventId) : InReplyToDetails(eventId)
class Error(eventId: EventId, val message: String) : InReplyToDetails(eventId)
}
fun InReplyTo.map( fun InReplyTo.map(
permalinkParser: PermalinkParser, permalinkParser: PermalinkParser,
) = when (this) { ) = when (this) {
is InReplyTo.Ready -> InReplyToDetails( is InReplyTo.Ready -> InReplyToDetails.Ready(
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
senderProfile = senderProfile, senderProfile = senderProfile,
@ -55,5 +60,7 @@ fun InReplyTo.map(
else -> null else -> null
} }
) )
else -> null is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
} }

View file

@ -66,7 +66,7 @@ internal sealed interface InReplyToMetadata {
* Metadata can be either a thumbnail with a text OR just a text. * Metadata can be either a thumbnail with a text OR just a text.
*/ */
@Composable @Composable
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) { internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) { is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail( is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo( AttachmentThumbnailInfo(

View file

@ -26,7 +26,7 @@ sealed interface InReplyTo {
data class NotLoaded(val eventId: EventId) : InReplyTo data class NotLoaded(val eventId: EventId) : InReplyTo
/** The event details are pending to be fetched. We should **not** fetch them again. */ /** The event details are pending to be fetched. We should **not** fetch them again. */
data object Pending : InReplyTo data class Pending(val eventId: EventId) : InReplyTo
/** The event details are available. */ /** The event details are available. */
data class Ready( data class Ready(
@ -44,5 +44,8 @@ sealed interface InReplyTo {
* If the reason for the failure is consistent on the server, we'd enter a loop * If the reason for the failure is consistent on the server, we'd enter a loop
* where we keep trying to fetch the same event. * where we keep trying to fetch the same event.
* */ * */
data object Error : InReplyTo data class Error(
val eventId: EventId,
val message: String,
) : InReplyTo
} }

View file

@ -57,9 +57,16 @@ class EventMessageMapper {
senderProfile = event.senderProfile.map(), senderProfile = event.senderProfile.map(),
) )
} }
is RepliedToEventDetails.Error -> InReplyTo.Error is RepliedToEventDetails.Error -> InReplyTo.Error(
is RepliedToEventDetails.Pending -> InReplyTo.Pending eventId = inReplyToId,
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId) message = event.message,
)
RepliedToEventDetails.Pending -> InReplyTo.Pending(
eventId = inReplyToId,
)
is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(
eventId = inReplyToId
)
} }
} }
MessageContent( MessageContent(