Reply : move InReplyTo and related to matrix.ui module.

This commit is contained in:
ganfra 2024-06-20 17:38:56 +02:00
parent 5724c62c67
commit 78da033541
15 changed files with 221 additions and 172 deletions

View file

@ -0,0 +1,197 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InReplyToBox(
inReplyTo: InReplyToDetails,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (inReplyTo) {
is InReplyToDetails.Ready -> {
ReplyToReadyContent(
senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(),
)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo)
is InReplyToDetails.Loading ->
ReplyToLoadingContent()
}
}
}
@Composable
private fun ReplyToReadyContent(
senderId: UserId,
senderProfile: ProfileTimelineDetails,
metadata: InReplyToMetadata?,
modifier: Modifier = Modifier,
) {
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else {
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
}
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail(
info = metadata.attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.width(8.dp))
}
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
Column(verticalArrangement = Arrangement.SpaceBetween) {
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
)
ReplyToContentText(metadata)
}
}
}
@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
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed)
InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key)
is InReplyToMetadata.Text -> metadata.text
is InReplyToMetadata.Thumbnail -> metadata.text
null -> ""
}
val iconResourceId = when (metadata) {
InReplyToMetadata.Redacted -> CompoundDrawables.ic_compound_delete
InReplyToMetadata.UnableToDecrypt -> CompoundDrawables.ic_compound_time
else -> null
}
val fontStyle = when (metadata) {
is InReplyToMetadata.Informative -> FontStyle.Italic
else -> FontStyle.Normal
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (iconResourceId != null) {
Icon(
resourceId = iconResourceId,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
fontStyle = fontStyle,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -0,0 +1,74 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
@Immutable
sealed interface InReplyToDetails {
data class Ready(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileTimelineDetails,
val eventContent: EventContent?,
val textContent: String?,
) : InReplyToDetails
data class Loading(val eventId: EventId) : InReplyToDetails
data class Error(val eventId: EventId, val message: String) : InReplyToDetails
}
fun InReplyToDetails.eventId() = when (this) {
is InReplyToDetails.Ready -> eventId
is InReplyToDetails.Loading -> eventId
is InReplyToDetails.Error -> eventId
}
fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
is InReplyTo.Ready -> InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
senderProfile = senderProfile,
eventContent = content,
textContent = when (content) {
is MessageContent -> {
val messageContent = content as MessageContent
(messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
}
is StickerContent -> {
val stickerContent = content as StickerContent
stickerContent.body
}
else -> null
}
)
is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
}

View file

@ -0,0 +1,139 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
val text: String = attachmentThumbnailInfo.textContent.orEmpty()
}
data class Text(
val text: String
) : InReplyToMetadata
sealed interface Informative : InReplyToMetadata
data object Redacted : Informative
data object UnableToDecrypt : Informative
}
/**
* Computes metadata for the in reply to message.
*
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
)
is VideoMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
)
is FileMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
)
is LocationMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_shared_location),
type = AttachmentThumbnailType.Location,
)
)
is AudioMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
)
is VoiceMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_voice_message),
type = AttachmentThumbnailType.Voice,
)
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
}
is StickerContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = eventContent.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash,
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
)
is RedactedContent -> InReplyToMetadata.Redacted
is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
is StateContent,
UnknownContent,
is LegacyCallInviteContent,
is CallNotifyContent,
null -> null
}

View file

@ -0,0 +1,136 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
@Composable
fun SenderName(
senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderNameMode: SenderNameMode,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (senderProfile) {
is ProfileTimelineDetails.Error,
ProfileTimelineDetails.Pending,
ProfileTimelineDetails.Unavailable -> {
MainText(text = senderId.value, mode = senderNameMode)
}
is ProfileTimelineDetails.Ready -> {
val displayName = senderProfile.displayName
if (displayName.isNullOrEmpty()) {
MainText(text = senderId.value, mode = senderNameMode)
} else {
MainText(text = displayName, mode = senderNameMode)
if (senderProfile.displayNameAmbiguous) {
SecondaryText(text = senderId.value, mode = senderNameMode)
}
}
}
}
}
}
@Composable
private fun RowScope.MainText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodyMdMedium
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodySmMedium
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
val color = when (mode) {
is SenderNameMode.Timeline -> mode.mainColor
SenderNameMode.ActionList,
SenderNameMode.Reply -> MaterialTheme.colorScheme.primary
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun RowScope.SecondaryText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodySmRegular
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodyXsRegular
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@Composable
internal fun SenderNamePreview(
@PreviewParameter(SenderNameDataProvider::class) senderNameData: SenderNameData,
) = ElementPreview {
SenderName(
senderId = senderNameData.userId,
senderProfile = senderNameData.profileTimelineDetails,
senderNameMode = senderNameData.senderNameMode,
)
}

View file

@ -0,0 +1,66 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class SenderNameData(
val userId: UserId,
val profileTimelineDetails: ProfileTimelineDetails,
val senderNameMode: SenderNameMode,
)
open class SenderNameDataProvider : PreviewParameterProvider<SenderNameData> {
override val values: Sequence<SenderNameData>
get() = sequenceOf(
SenderNameMode.Timeline(mainColor = Color.Red),
SenderNameMode.Reply,
SenderNameMode.ActionList,
)
.flatMap { senderNameMode ->
sequenceOf(
aSenderNameData(
senderNameMode = senderNameMode,
),
aSenderNameData(
senderNameMode = senderNameMode,
displayNameAmbiguous = true,
),
SenderNameData(
senderNameMode = senderNameMode,
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileTimelineDetails = ProfileTimelineDetails.Unavailable,
),
)
}
}
private fun aSenderNameData(
senderNameMode: SenderNameMode,
displayNameAmbiguous: Boolean = false,
) = SenderNameData(
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileTimelineDetails = ProfileTimelineDetails.Ready(
displayName = "Alice ${senderNameMode.javaClass.simpleName}",
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = null
),
senderNameMode = senderNameMode,
)

View file

@ -0,0 +1,27 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
sealed interface SenderNameMode {
data class Timeline(val mainColor: Color) : SenderNameMode
data object Reply : SenderNameMode
data object ActionList : SenderNameMode
}