Merge pull request #3240 from element-hq/feature/valere/message_shields

Timeline UI | MessageShield Support
This commit is contained in:
Benoit Marty
2024-08-16 14:25:33 +02:00
committed by GitHub
48 changed files with 689 additions and 10 deletions

View File

@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -121,6 +122,16 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemPollActionList(),
),
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent().copy(
reactionsState = reactionsState,
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
displayEmojiReactions = true,
actions = aTimelineItemActionList(),
)
),
)
}
}

View File

@@ -55,6 +55,7 @@ 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.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@@ -181,7 +182,14 @@ private fun SheetContent(
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(14.dp))
if (target.event.messageShield != null) {
MessageShieldView(
shield = target.event.messageShield,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
} else {
Spacer(modifier = Modifier.height(14.dp))
}
HorizontalDivider()
}
}

View File

@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration
sealed interface TimelineEvents {
@@ -27,6 +28,9 @@ sealed interface TimelineEvents {
data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents
data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
data object HideShieldDialog : TimelineEvents
/**
* Events coming from a timeline item.
*/

View File

@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
@@ -97,6 +98,7 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
@@ -151,6 +153,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
}
TimelineEvents.HideShieldDialog -> messageShield.value = null
is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
}
}
@@ -226,6 +230,7 @@ class TimelinePresenter @AssistedInject constructor(
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
eventSink = { handleEvents(it) }
)
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@@ -31,6 +32,8 @@ data class TimelineState(
val newEventState: NewEventState,
val isLive: Boolean,
val focusRequestState: FocusRequestState,
// If not null, info will be rendered in a dialog
val messageShield: MessageShield?,
val eventSink: (TimelineEvents) -> Unit,
) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }

View File

@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
import kotlinx.collections.immutable.ImmutableList
@@ -50,6 +51,7 @@ fun aTimelineState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
messageShield: MessageShield? = null,
eventSink: (TimelineEvents) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -65,6 +67,7 @@ fun aTimelineState(
newEventState = NewEventState.None,
isLive = isLive,
focusRequestState = focusRequestState,
messageShield = messageShield,
eventSink = eventSink,
)
}
@@ -138,6 +141,7 @@ internal fun aTimelineItemEvent(
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
messageShield: MessageShield? = null,
): TimelineItem.Event {
return TimelineItem.Event(
id = UUID.randomUUID().toString(),
@@ -161,7 +165,8 @@ internal fun aTimelineItemEvent(
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
origin = null
origin = null,
messageShield = messageShield,
)
}

View File

@@ -58,6 +58,7 @@ 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.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
@@ -68,12 +69,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
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.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlin.math.abs
@@ -124,6 +127,10 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
fun onShieldClick(shield: MessageShield) {
state.eventSink(TimelineEvents.ShowShieldDialog(shield))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
@@ -154,6 +161,7 @@ fun TimelineView(
focusedEventId = state.focusedEventId,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
onShieldClick = ::onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = ::inReplyToClick,
@@ -186,6 +194,17 @@ fun TimelineView(
)
}
}
MessageShieldDialog(state)
}
@Composable
private fun MessageShieldDialog(state: TimelineState) {
val messageShield = state.messageShield ?: return
AlertDialog(
content = messageShield.toText(),
onDismiss = { state.eventSink.invoke(TimelineEvents.HideShieldDialog) },
)
}
@Composable

View File

@@ -0,0 +1,62 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.toImmutableList
@PreviewsDayNight
@Composable
internal fun TimelineViewMessageShieldPreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
// For consistency, ensure that there is a message in the timeline (the last one) with an error.
val messageShield = aCriticalShield()
val items = listOf(
(timelineItems.first() as TimelineItem.Event).copy(messageShield = messageShield)
) + timelineItems.drop(1)
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = items.toImmutableList(),
messageShield = messageShield,
),
typingNotificationState = aTypingNotificationState(),
onUserDataClick = {},
onLinkClick = {},
onMessageClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
forceJumpToBottomVisibility = true,
)
}
}

View File

@@ -37,6 +37,7 @@ internal fun ATimelineItemEventRow(
isHighlighted = isHighlighted,
onClick = {},
onLongClick = {},
onShieldClick = {},
onUserDataClick = {},
onLinkClick = {},
inReplyToClick = {},

View File

@@ -0,0 +1,131 @@
/*
* 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
*
* https://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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun MessageShieldView(
shield: MessageShield,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Icon(
imageVector = shield.toIcon(),
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = shield.toText(),
style = ElementTheme.typography.fontBodySmMedium,
color = shield.toTextColor()
)
}
}
@Composable
internal fun MessageShield.toIconColor(): Color {
return when (isCritical) {
true -> ElementTheme.colors.iconCriticalPrimary
false -> ElementTheme.colors.iconSecondary
}
}
@Composable
private fun MessageShield.toTextColor(): Color {
return when (isCritical) {
true -> ElementTheme.colors.textCriticalPrimary
false -> ElementTheme.colors.textSecondary
}
}
@Composable
internal fun MessageShield.toText(): String {
return stringResource(
id = when (this) {
is MessageShield.AuthenticityNotGuaranteed -> CommonStrings.event_shield_reason_authenticity_not_guaranteed
is MessageShield.UnknownDevice -> CommonStrings.event_shield_reason_unknown_device
is MessageShield.UnsignedDevice -> CommonStrings.event_shield_reason_unsigned_device
is MessageShield.UnverifiedIdentity -> CommonStrings.event_shield_reason_unverified_identity
is MessageShield.SentInClear -> CommonStrings.event_shield_reason_sent_in_clear
}
)
}
@Composable
internal fun MessageShield.toIcon(): ImageVector {
return when (this) {
is MessageShield.AuthenticityNotGuaranteed -> CompoundIcons.Info()
is MessageShield.UnknownDevice,
is MessageShield.UnsignedDevice,
is MessageShield.UnverifiedIdentity -> CompoundIcons.HelpSolid()
is MessageShield.SentInClear -> CompoundIcons.LockOff()
}
}
@PreviewsDayNight
@Composable
internal fun MessageShieldViewPreview() {
ElementPreview {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageShieldView(
shield = MessageShield.UnknownDevice(true)
)
MessageShieldView(
shield = MessageShield.UnverifiedIdentity(true)
)
MessageShieldView(
shield = MessageShield.AuthenticityNotGuaranteed(false)
)
MessageShieldView(
shield = MessageShield.UnsignedDevice(false)
)
MessageShieldView(
shield = MessageShield.SentInClear(false)
)
}
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -33,26 +34,31 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.preview.ElementPreview
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.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineEventTimestampView(
event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier,
) {
val formattedTime = event.sentTime
val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
val isMessageEdited = event.content.isEdited()
val tint = if (hasUnrecoverableError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
val tint = if (hasUnrecoverableError || hasEncryptionCritical) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
@@ -77,13 +83,28 @@ fun TimelineEventTimestampView(
modifier = Modifier.size(15.dp, 18.dp),
)
}
event.messageShield?.let { shield ->
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = shield.toText(),
modifier = Modifier
.size(15.dp)
.clickable { onShieldClick(shield) },
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView(event = event)
TimelineEventTimestampView(
event = event,
onShieldClick = {},
)
}
object TimelineEventTimestampViewDefaults {

View File

@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event>
@@ -37,5 +38,11 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
aTimelineItemEvent().copy(
messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false),
),
aTimelineItemEvent().copy(
messageShield = MessageShield.UnknownDevice(isCritical = true),
),
)
}

View File

@@ -90,6 +90,7 @@ 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.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
@@ -118,6 +119,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
@@ -180,6 +182,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
@@ -198,6 +201,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
@@ -253,6 +257,7 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit,
@@ -320,6 +325,7 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
onShieldClick = onShieldClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onLinkClick = onLinkClick,
@@ -380,6 +386,7 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onLinkClick: (String) -> Unit,
@@ -420,6 +427,7 @@ private fun MessageEventBubbleContent(
@Composable
fun WithTimestampLayout(
timestampPosition: TimestampPosition,
onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit,
@@ -430,6 +438,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
modifier = Modifier
// Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp)
@@ -450,6 +459,7 @@ private fun MessageEventBubbleContent(
overlay = {
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
@@ -460,6 +470,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -507,6 +518,7 @@ private fun MessageEventBubbleContent(
val contentWithTimestamp = @Composable {
WithTimestampLayout(
timestampPosition = timestampPosition,
onShieldClick = onShieldClick,
canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier,
) { onContentLayoutChange ->
@@ -519,6 +531,7 @@ private fun MessageEventBubbleContent(
)
}
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val inReplyToModifier = Modifier

View File

@@ -0,0 +1,78 @@
/*
* 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.foundation.layout.Column
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
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.MessageShield
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowShieldPreview() = ElementPreview {
Column {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = true,
content = aTimelineItemTextContent(
body = "Message sent from unsigned device"
),
groupPosition = TimelineItemGroupPosition.First,
messageShield = aCriticalShield()
),
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
content = aTimelineItemTextContent(
body = "Short Message with authenticity warning"
),
groupPosition = TimelineItemGroupPosition.Middle,
messageShield = aWarningShield()
),
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemImageContent().copy(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = aCriticalShield()
),
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemImageContent().copy(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = aWarningShield()
),
)
}
}
private fun aWarningShield() = MessageShield.AuthenticityNotGuaranteed(isCritical = false)
internal fun aCriticalShield() = MessageShield.UnverifiedIdentity(isCritical = true)

View File

@@ -36,6 +36,7 @@ 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
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@Composable
fun TimelineItemGroupedEventsRow(
@@ -46,6 +47,7 @@ fun TimelineItemGroupedEventsRow(
focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@@ -72,6 +74,7 @@ fun TimelineItemGroupedEventsRow(
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@@ -95,6 +98,7 @@ private fun TimelineItemGroupedEventsRowContent(
isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@@ -127,6 +131,7 @@ private fun TimelineItemGroupedEventsRowContent(
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@@ -168,6 +173,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
@@ -192,6 +198,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},

View File

@@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
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.timeline.item.event.MessageShield
@Composable
internal fun TimelineItemRow(
@@ -49,6 +50,7 @@ internal fun TimelineItemRow(
onLinkClick: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -110,6 +112,7 @@ internal fun TimelineItemRow(
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onShieldClick = onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = inReplyToClick,
@@ -132,6 +135,7 @@ internal fun TimelineItemRow(
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,

View File

@@ -86,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
messageShield = currentTimelineItem.event.messageShield,
)
}

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
@@ -83,6 +84,7 @@ sealed interface TimelineItem {
val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
val messageShield: MessageShield?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine

View File

@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -48,6 +49,7 @@ internal fun aMessageEvent(
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
messageShield: MessageShield? = null,
) = TimelineItem.Event(
id = eventId?.value.orEmpty(),
eventId = eventId,
@@ -66,5 +68,6 @@ internal fun aMessageEvent(
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
origin = null
origin = null,
messageShield = messageShield,
)

View File

@@ -24,6 +24,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -591,6 +592,26 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}
}
@Test
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
val shield = aCriticalShield()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
awaitItem().also { state ->
assertThat(state.messageShield).isEqualTo(shield)
state.eventSink(TimelineEvents.HideShieldDialog)
}
awaitItem().also { state ->
assertThat(state.messageShield).isNull()
}
}
}
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeTimeline(

View File

@@ -22,17 +22,21 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
@@ -97,6 +101,47 @@ class TimelineViewTest {
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
}
@Test
fun `show shield dialog`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
rule.setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent(
// Do not use a Text because EditorStyledText cannot be used in UI test.
content = aTimelineItemImageContent(),
messageShield = MessageShield.UnverifiedIdentity(true),
),
),
eventSink = eventsRecorder,
),
)
val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity)
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList(
listOf(
TimelineEvents.OnScrollFinished(0),
TimelineEvents.OnScrollFinished(0),
TimelineEvents.OnScrollFinished(0),
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
)
)
}
@Test
fun `hide shield dialog`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
rule.setTimelineView(
state = aTimelineState(
isLive = false,
eventSink = eventsRecorder,
messageShield = aCriticalShield(),
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(

View File

@@ -50,7 +50,8 @@ class TimelineItemGrouperTest {
inReplyTo = null,
isThreaded = false,
debugInfo = aTimelineItemDebugInfo(),
origin = null
origin = null,
messageShield = null,
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))

View File

@@ -101,7 +101,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
originalJson = null,
latestEditedJson = null
),
origin = null
origin = null,
messageShield = null,
),
)
)

View File

@@ -0,0 +1,92 @@
/*
* 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.dialogs
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlertDialog(
content: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
submitText: String = AlertDialogDefaults.submitText,
) {
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
AlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onDismiss,
)
}
}
@Composable
private fun AlertDialogContent(
content: String,
onSubmitClick: () -> Unit,
title: String? = AlertDialogDefaults.title,
submitText: String = AlertDialogDefaults.submitText,
) {
SimpleAlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmitClick,
)
}
object AlertDialogDefaults {
val title: String? @Composable get() = null
val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok)
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun AlertDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
AlertDialogContent(
content = "Content",
onSubmitClick = {},
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AlertDialogPreview() = ElementPreview {
AlertDialog(
content = "Content",
onDismiss = {},
)
}

View File

@@ -39,6 +39,7 @@ data class EventTimelineItem(
val content: EventContent,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
val messageShield: MessageShield?,
) {
fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo

View File

@@ -0,0 +1,46 @@
/*
* 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
*
* https://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.api.timeline.item.event
import androidx.compose.runtime.Immutable
@Immutable
sealed interface MessageShield {
/** Not enough information available to check the authenticity. */
data class AuthenticityNotGuaranteed(val isCritical: Boolean) : MessageShield
/** The sending device isn't yet known by the Client. */
data class UnknownDevice(val isCritical: Boolean) : MessageShield
/** The sending device hasn't been verified by the sender. */
data class UnsignedDevice(val isCritical: Boolean) : MessageShield
/** The sender hasn't been verified by the Client's user. */
data class UnverifiedIdentity(val isCritical: Boolean) : MessageShield
/** An unencrypted event in an encrypted room. */
data class SentInClear(val isCritical: Boolean) : MessageShield
}
val MessageShield.isCritical: Boolean
get() = when (this) {
is MessageShield.AuthenticityNotGuaranteed -> isCritical
is MessageShield.UnknownDevice -> isCritical
is MessageShield.UnsignedDevice -> isCritical
is MessageShield.UnverifiedIdentity -> isCritical
is MessageShield.SentInClear -> isCritical
}

View File

@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
@@ -31,6 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState
import uniffi.matrix_sdk_common.ShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@@ -56,7 +59,8 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
timestamp = it.timestamp().toLong(),
content = contentMapper.map(it.content()),
debugInfo = it.debugInfo().map(),
origin = it.origin()?.map()
origin = it.origin()?.map(),
messageShield = it.getShield(false)?.map(),
)
}
}
@@ -129,3 +133,24 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION
}
}
private fun ShieldState?.map(): MessageShield? {
this ?: return null
val shieldStateCode = when (this) {
is ShieldState.Grey -> code
is ShieldState.Red -> code
ShieldState.None -> null
} ?: return null
val isCritical = when (this) {
ShieldState.None,
is ShieldState.Grey -> false
is ShieldState.Red -> true
}
return when (shieldStateCode) {
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
}
}

View File

@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@@ -58,6 +59,7 @@ fun anEventTimelineItem(
timestamp: Long = 0L,
content: EventContent = aProfileChangeMessageContent(),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
messageShield: MessageShield? = null,
) = EventTimelineItem(
eventId = eventId,
transactionId = transactionId,
@@ -75,6 +77,7 @@ fun anEventTimelineItem(
content = content,
debugInfo = debugInfo,
origin = null,
messageShield = messageShield,
)
fun aProfileTimelineDetails(

View File

@@ -120,6 +120,7 @@ class KonsistPreviewTest {
"TextComposerVoicePreview",
"TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview",
"TimelineItemEventRowWithManyReactionsPreview",
"TimelineItemEventRowWithRRPreview",
@@ -128,6 +129,7 @@ class KonsistPreviewTest {
"TimelineItemGroupedEventsRowContentExpandedPreview",
"TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview",
"UserAvatarColorsPreview",
)
.assertTrue(