Merge pull request #2384 from element-hq/feature/bma/replyOnRedactedRendering
Reply on redacted and UTD rendering
This commit is contained in:
commit
58a4cc251d
224 changed files with 308 additions and 30 deletions
1
changelog.d/2318.misc
Normal file
1
changelog.d/2318.misc
Normal file
|
|
@ -0,0 +1 @@
|
|||
Render correctly in reply to data when Event cannot be decrypted or has been redacted
|
||||
|
|
@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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
|
||||
|
|
@ -92,6 +93,7 @@ import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
|||
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.AvatarData
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
|
||||
|
|
@ -648,18 +650,53 @@ private fun ReplyToContent(
|
|||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = metadata?.text.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ReplyToContentText(metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithReplyInformativePreview(
|
||||
@PreviewParameter(InReplyToDetailsInformativeProvider::class) inReplyToDetails: InReplyToDetails,
|
||||
) = ElementPreview {
|
||||
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
|
||||
}
|
||||
|
||||
class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
RedactedContent,
|
||||
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
eventContent = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,11 @@ import kotlinx.collections.immutable.persistentMapOf
|
|||
internal fun TimelineItemEventRowWithReplyPreview(
|
||||
@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails,
|
||||
) = ElementPreview {
|
||||
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach {
|
||||
ATimelineItemEventRow(
|
||||
|
|
@ -83,7 +88,7 @@ internal fun TimelineItemEventRowWithReplyPreview(
|
|||
}
|
||||
}
|
||||
|
||||
class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
||||
open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
aMessageContent(
|
||||
|
|
@ -156,7 +161,7 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
|||
type = type,
|
||||
)
|
||||
|
||||
private fun aInReplyToDetails(
|
||||
protected fun aInReplyToDetails(
|
||||
eventContent: EventContent,
|
||||
) = InReplyToDetails(
|
||||
eventId = EventId("\$event"),
|
||||
|
|
|
|||
|
|
@ -21,12 +21,20 @@ import androidx.compose.runtime.Immutable
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
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.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
|
||||
|
|
@ -35,17 +43,20 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
|
||||
@Immutable
|
||||
internal sealed interface InReplyToMetadata {
|
||||
val text: String?
|
||||
|
||||
data class Thumbnail(
|
||||
val attachmentThumbnailInfo: AttachmentThumbnailInfo
|
||||
) : InReplyToMetadata {
|
||||
override val text: String? = attachmentThumbnailInfo.textContent
|
||||
val text: String = attachmentThumbnailInfo.textContent.orEmpty()
|
||||
}
|
||||
|
||||
data class Text(
|
||||
override val text: String
|
||||
val text: String
|
||||
) : InReplyToMetadata
|
||||
|
||||
sealed interface Informative : InReplyToMetadata
|
||||
|
||||
data object Redacted : Informative
|
||||
data object UnableToDecrypt : Informative
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,7 +114,8 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
|
|||
AttachmentThumbnailInfo(
|
||||
thumbnailSource = MediaSource(eventContent.url),
|
||||
textContent = eventContent.body,
|
||||
type = AttachmentThumbnailType.Image
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = eventContent.info.blurhash,
|
||||
)
|
||||
)
|
||||
is PollContent -> InReplyToMetadata.Thumbnail(
|
||||
|
|
@ -112,5 +124,13 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
|
|||
type = AttachmentThumbnailType.Poll,
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
is RedactedContent -> InReplyToMetadata.Redacted
|
||||
is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt
|
||||
is FailedToParseMessageLikeContent,
|
||||
is FailedToParseStateContent,
|
||||
is ProfileChangeContent,
|
||||
is RoomMembershipContent,
|
||||
is StateContent,
|
||||
UnknownContent,
|
||||
null -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,23 @@ 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.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
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.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
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.test.AN_EVENT_ID
|
||||
|
|
@ -45,11 +55,13 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InReplyToMetadataKtTest {
|
||||
|
|
@ -72,7 +84,7 @@ class InReplyToMetadataKtTest {
|
|||
messageType = ImageMessageType(
|
||||
body = "body",
|
||||
source = aMediaSource(),
|
||||
info = null,
|
||||
info = anImageInfo(),
|
||||
)
|
||||
)
|
||||
).metadata()
|
||||
|
|
@ -84,7 +96,33 @@ class InReplyToMetadataKtTest {
|
|||
thumbnailSource = aMediaSource(),
|
||||
textContent = "body",
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = null,
|
||||
blurHash = A_BLUR_HASH,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sticker message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = StickerContent(
|
||||
body = "body",
|
||||
info = anImageInfo(),
|
||||
url = "url"
|
||||
)
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isEqualTo(
|
||||
InReplyToMetadata.Thumbnail(
|
||||
attachmentThumbnailInfo = AttachmentThumbnailInfo(
|
||||
thumbnailSource = aMediaSource(url = "url"),
|
||||
textContent = "body",
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = A_BLUR_HASH,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -100,16 +138,7 @@ class InReplyToMetadataKtTest {
|
|||
messageType = VideoMessageType(
|
||||
body = "body",
|
||||
source = aMediaSource(),
|
||||
info = VideoInfo(
|
||||
duration = null,
|
||||
height = null,
|
||||
width = null,
|
||||
mimetype = null,
|
||||
size = null,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = aMediaSource(),
|
||||
blurhash = null
|
||||
),
|
||||
info = aVideoInfo(),
|
||||
)
|
||||
)
|
||||
).metadata()
|
||||
|
|
@ -121,7 +150,7 @@ class InReplyToMetadataKtTest {
|
|||
thumbnailSource = aMediaSource(),
|
||||
textContent = "body",
|
||||
type = AttachmentThumbnailType.Video,
|
||||
blurHash = null,
|
||||
blurHash = A_BLUR_HASH,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -277,11 +306,115 @@ class InReplyToMetadataKtTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `any other content`() = runTest {
|
||||
fun `redacted content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = RedactedContent
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unable to decrypt content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed to parse message content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = FailedToParseMessageLikeContent("", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed to parse state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = FailedToParseStateContent("", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile change content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = ProfileChangeContent("", "", "", "")
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `room membership content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = RoomMembershipContent(A_USER_ID, null)
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = StateContent("", OtherState.RoomJoinRules)
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = UnknownContent
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetails(
|
||||
eventContent = null
|
||||
).metadata()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it).isNull()
|
||||
|
|
@ -306,6 +439,31 @@ fun anInReplyToDetails(
|
|||
textContent = textContent,
|
||||
)
|
||||
|
||||
fun aVideoInfo(): VideoInfo {
|
||||
return VideoInfo(
|
||||
duration = 1.minutes,
|
||||
height = 100,
|
||||
width = 100,
|
||||
mimetype = "video/mp4",
|
||||
size = 1000,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = aMediaSource(),
|
||||
blurhash = A_BLUR_HASH,
|
||||
)
|
||||
}
|
||||
|
||||
fun anImageInfo(): ImageInfo {
|
||||
return ImageInfo(
|
||||
height = 100,
|
||||
width = 100,
|
||||
mimetype = "image/jpeg",
|
||||
size = 1000,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = aMediaSource(),
|
||||
blurhash = A_BLUR_HASH,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun testEnv(content: @Composable () -> Any?): Any? {
|
||||
var result: Any? = null
|
||||
|
|
|
|||
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