Iterate on shield mapping and rendering

Also handle click on the timeline and information displayed on long click.
This commit is contained in:
Benoit Marty 2024-08-14 17:02:21 +02:00 committed by Benoit Marty
parent 5dceba8146
commit 34268a30ea
24 changed files with 399 additions and 245 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.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent 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.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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -121,6 +122,16 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemPollActionList(), 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@ -181,7 +182,14 @@ private fun SheetContent(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .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() 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.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration import kotlin.time.Duration
sealed interface TimelineEvents { sealed interface TimelineEvents {
@ -27,6 +28,9 @@ sealed interface TimelineEvents {
data object OnFocusEventRender : TimelineEvents data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents data object JumpToLive : TimelineEvents
data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
data object HideShieldDialog : TimelineEvents
/** /**
* Events coming from a timeline item. * 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.isDm
import io.element.android.libraries.matrix.api.room.roomMembers 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.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.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
@ -97,6 +98,7 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) } val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
@ -151,6 +153,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.JumpToLive -> { is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive() 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, newEventState = newEventState.value,
isLive = isLive, isLive = isLive,
focusRequestState = focusRequestState.value, focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
eventSink = { handleEvents(it) } 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.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration import kotlin.time.Duration
@ -31,6 +32,8 @@ data class TimelineState(
val newEventState: NewEventState, val newEventState: NewEventState,
val isLive: Boolean, val isLive: Boolean,
val focusRequestState: FocusRequestState, val focusRequestState: FocusRequestState,
// If not null, info will be rendered in a dialog
val messageShield: MessageShield?,
val eventSink: (TimelineEvents) -> Unit, val eventSink: (TimelineEvents) -> Unit,
) { ) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event } val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }

View file

@ -51,6 +51,7 @@ fun aTimelineState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1, focusedEventIndex: Int = -1,
isLive: Boolean = true, isLive: Boolean = true,
messageShield: MessageShield? = null,
eventSink: (TimelineEvents) -> Unit = {}, eventSink: (TimelineEvents) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -66,6 +67,7 @@ fun aTimelineState(
newEventState = NewEventState.None, newEventState = NewEventState.None,
isLive = isLive, isLive = isLive,
focusRequestState = focusRequestState, focusRequestState = focusRequestState,
messageShield = messageShield,
eventSink = eventSink, eventSink = eventSink,
) )
} }

View file

@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView 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.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon 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.EventId
import io.element.android.libraries.matrix.api.core.UserId 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -124,6 +127,10 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId)) 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
@ -154,6 +161,7 @@ fun TimelineView(
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
onClick = onMessageClick, onClick = onMessageClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
onShieldClick = ::onShieldClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
inReplyToClick = ::inReplyToClick, 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 @Composable

View file

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

View file

@ -1,25 +0,0 @@
/*
* 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 io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
sealed class MessageShieldPosition {
data class InBubble(val messageShield: MessageShield) : MessageShieldPosition()
data class OutOfBubble(val messageShield: MessageShield) : MessageShieldPosition()
object None : MessageShieldPosition()
}

View file

@ -16,19 +16,18 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -36,52 +35,71 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun MessageShieldView( internal fun MessageShieldView(
isMine: Boolean = false,
shield: MessageShield, shield: MessageShield,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val borderColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.borderCriticalPrimary else ElementTheme.colors.bgSubtlePrimary
val iconColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary
val backgroundBubbleColor = when {
isMine -> ElementTheme.colors.messageFromMeBackground
else -> ElementTheme.colors.messageFromOtherBackground
}
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.CenterVertically,
modifier = modifier modifier = modifier,
.background(backgroundBubbleColor, RoundedCornerShape(8.dp))
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.padding(8.dp)
) { ) {
Icon( Icon(
imageVector = shield.toIcon(), imageVector = shield.toIcon(),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(15.dp), modifier = Modifier.size(15.dp),
tint = iconColor, tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.size(4.dp)) Spacer(modifier = Modifier.size(4.dp))
val textColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
Text( Text(
text = shield.message, text = shield.toText(),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodySmMedium,
color = textColor color = shield.toTextColor()
) )
} }
} }
@Composable @Composable
private fun MessageShield.toIcon(): ImageVector { internal fun MessageShield.toIconColor(): Color {
return when (this.color) { return when (isCritical()) {
ShieldColor.RED -> CompoundIcons.Error() true -> ElementTheme.colors.iconCriticalPrimary
ShieldColor.GREY -> CompoundIcons.InfoSolid() 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,
is MessageShield.UnverifiedIdentity -> CompoundIcons.Admin()
is MessageShield.UnknownDevice,
is MessageShield.UnsignedDevice -> CompoundIcons.HelpSolid()
is MessageShield.SentInClear -> CompoundIcons.KeyOff()
} }
} }
@ -89,31 +107,24 @@ private fun MessageShield.toIcon(): ImageVector {
@Composable @Composable
internal fun MessageShieldViewPreview() { internal fun MessageShieldViewPreview() {
ElementPreview { ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageShieldView( MessageShieldView(
shield = MessageShield( shield = MessageShield.UnknownDevice(true)
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
color = ShieldColor.GREY
)
) )
MessageShieldView( MessageShieldView(
isMine = true, shield = MessageShield.UnverifiedIdentity(true)
shield = MessageShield(
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
color = ShieldColor.GREY
)
) )
MessageShieldView( MessageShieldView(
shield = MessageShield( shield = MessageShield.AuthenticityNotGuaranteed(false)
message = "Encrypted by a device not verified by its owner.",
color = ShieldColor.RED
)
) )
MessageShieldView( MessageShieldView(
shield = MessageShield( shield = MessageShield.UnsignedDevice(false)
message = "Encrypted by an unknown or deleted device.", )
color = ShieldColor.RED MessageShieldView(
) shield = MessageShield.SentInClear(false)
) )
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text 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.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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineEventTimestampView( fun TimelineEventTimestampView(
event: TimelineItem.Event, event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable
val hasEncryptionCritical = event.messageShield?.isCritical().orFalse()
val isMessageEdited = event.content.isEdited() 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( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isMessageEdited) { if (isMessageEdited) {
@ -77,13 +83,28 @@ fun TimelineEventTimestampView(
modifier = Modifier.size(15.dp, 18.dp), modifier = Modifier.size(15.dp, 18.dp),
) )
} }
event.messageShield?.let { shield ->
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = null,
modifier = Modifier
.size(15.dp)
.clickable { onShieldClick(shield) },
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))
}
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview { internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView(event = event) TimelineEventTimestampView(
event = event,
onShieldClick = {},
)
} }
object TimelineEventTimestampViewDefaults { 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.TimelineItem
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.libraries.matrix.api.timeline.item.event.LocalEventSendState 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> { class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event> override val values: Sequence<TimelineItem.Event>
@ -37,5 +38,11 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"), localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true), content = aTimelineItemTextContent().copy(isEdited = true),
), ),
aTimelineItemEvent().copy(
messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false),
),
aTimelineItemEvent().copy(
messageShield = MessageShield.UnknownDevice(isCritical = true),
),
) )
} }

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@ -37,7 +36,6 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -52,7 +50,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -78,8 +75,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aGreyShield
import io.element.android.features.messages.impl.timeline.model.event.aRedShield
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
@ -95,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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId 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.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.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
@ -123,6 +119,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -185,6 +182,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -203,6 +201,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -258,6 +257,7 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit, onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit, onReactionClick: (emoji: String) -> Unit,
@ -281,7 +281,6 @@ private fun TimelineItemEventRowContent(
val ( val (
sender, sender,
message, message,
shield,
reactions, reactions,
) = createRefs() ) = createRefs()
@ -326,6 +325,7 @@ private fun TimelineItemEventRowContent(
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
onShieldClick = onShieldClick,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -333,29 +333,6 @@ private fun TimelineItemEventRowContent(
) )
} }
val shieldPosition = event.shieldPosition()
if (shieldPosition is MessageShieldPosition.OutOfBubble) {
MessageShieldView(
isMine = event.isMine,
shield = shieldPosition.messageShield,
modifier = Modifier
.constrainAs(shield) {
top.linkTo(message.bottom, margin = (-4).dp)
linkStartOrEnd(event)
}
.padding(
// Note: due to the applied constraints, start is left for other's message and right for mine
// In design we want a offset of 6.dp compare to the bubble, so start is 22.dp (16 + 6)
start = when {
event.isMine -> 22.dp
timelineRoomInfo.isDm -> 22.dp
else -> 22.dp + BUBBLE_INCOMING_OFFSET
},
end = 16.dp
),
)
}
// Reactions // Reactions
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView( TimelineItemReactionsView(
@ -367,11 +344,7 @@ private fun TimelineItemEventRowContent(
onMoreReactionsClick = { onMoreReactionsClick(event) }, onMoreReactionsClick = { onMoreReactionsClick(event) },
modifier = Modifier modifier = Modifier
.constrainAs(reactions) { .constrainAs(reactions) {
if (shieldPosition is MessageShieldPosition.OutOfBubble) { top.linkTo(message.bottom, margin = (-4).dp)
top.linkTo(shield.bottom, margin = (-4).dp)
} else {
top.linkTo(message.bottom, margin = (-4).dp)
}
linkStartOrEnd(event) linkStartOrEnd(event)
} }
.zIndex(1f) .zIndex(1f)
@ -413,6 +386,7 @@ private fun MessageSenderInformation(
@Composable @Composable
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
@ -453,6 +427,7 @@ private fun MessageEventBubbleContent(
@Composable @Composable
fun WithTimestampLayout( fun WithTimestampLayout(
timestampPosition: TimestampPosition, timestampPosition: TimestampPosition,
onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canShrinkContent: Boolean = false, canShrinkContent: Boolean = false,
content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit, content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit,
@ -463,6 +438,7 @@ private fun MessageEventBubbleContent(
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onShieldClick = onShieldClick,
modifier = Modifier modifier = Modifier
// Outer padding // Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = 4.dp)
@ -483,6 +459,7 @@ private fun MessageEventBubbleContent(
overlay = { overlay = {
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onShieldClick = onShieldClick,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
@ -493,6 +470,7 @@ private fun MessageEventBubbleContent(
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onShieldClick = onShieldClick,
modifier = Modifier modifier = Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
@ -505,7 +483,6 @@ private fun MessageEventBubbleContent(
@Composable @Composable
fun CommonLayout( fun CommonLayout(
timestampPosition: TimestampPosition, timestampPosition: TimestampPosition,
messageShieldPosition: MessageShieldPosition,
showThreadDecoration: Boolean, showThreadDecoration: Boolean,
inReplyToDetails: InReplyToDetails?, inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -541,35 +518,20 @@ private fun MessageEventBubbleContent(
val contentWithTimestamp = @Composable { val contentWithTimestamp = @Composable {
WithTimestampLayout( WithTimestampLayout(
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
onShieldClick = onShieldClick,
canShrinkContent = canShrinkContent, canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier, modifier = timestampLayoutModifier,
) { onContentLayoutChange -> ) { onContentLayoutChange ->
TimelineItemEventContentView(
if (messageShieldPosition is MessageShieldPosition.InBubble) { content = event.content,
Column { onLinkClick = onLinkClick,
TimelineItemEventContentView( eventSink = eventSink,
content = event.content, onContentLayoutChange = onContentLayoutChange,
onLinkClick = onLinkClick, modifier = contentModifier
eventSink = eventSink, )
onContentLayoutChange = onContentLayoutChange,
modifier = contentModifier
)
MessageShieldView(
modifier = Modifier.padding(start = 8.dp, end = 8.dp),
shield = messageShieldPosition.messageShield,
)
}
} else {
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
onContentLayoutChange = onContentLayoutChange,
modifier = contentModifier
)
}
} }
} }
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
val inReplyToModifier = Modifier val inReplyToModifier = Modifier
@ -602,11 +564,9 @@ private fun MessageEventBubbleContent(
is TimelineItemPollContent -> TimestampPosition.Below is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default else -> TimestampPosition.Default
} }
val messageShieldPosition = event.shieldPosition()
CommonLayout( CommonLayout(
showThreadDecoration = event.isThreaded, showThreadDecoration = event.isThreaded,
messageShieldPosition = messageShieldPosition, timestampPosition = timestampPosition,
timestampPosition = if (messageShieldPosition is MessageShieldPosition.InBubble) TimestampPosition.Below else timestampPosition,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier.semantics(mergeDescendants = true) { modifier = bubbleModifier.semantics(mergeDescendants = true) {
@ -643,83 +603,3 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
} }
} }
} }
@Preview(
name = "Encryption Shields"
)
@Preview(
name = "Encryption Shields - Night",
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
internal fun TimelineItemEventRowShieldsPreview() = 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 = aRedShield()
),
)
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 = aGreyShield()
),
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemImageContent().copy(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = aRedShield()
),
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemImageContent().copy(
aspectRatio = 2.5f
),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = aGreyShield()
),
)
}
}
@Composable
@ReadOnlyComposable
private fun TimelineItem.Event.shieldPosition(): MessageShieldPosition {
val shield = this.messageShield ?: return MessageShieldPosition.None
// sdk returns raw human readable strings, add i18n support
val localizedMessage = when (shield.message) {
"The authenticity of this encrypted message can't be guaranteed on this device." -> stringResource(
CommonStrings.event_shield_reason_authenticity_not_guaranteed
)
"Encrypted by a device not verified by its owner." -> stringResource(CommonStrings.event_shield_reason_unsigned_device)
"Encrypted by an unknown or deleted device." -> stringResource(CommonStrings.event_shield_reason_unknown_device)
"Encrypted by an unverified user." -> stringResource(CommonStrings.event_shield_reason_unverified_identity)
else -> shield.message
}
val localShield = shield.copy(message = localizedMessage)
return when (this.content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemStickerContent,
is TimelineItemLocationContent,
is TimelineItemPollContent -> MessageShieldPosition.OutOfBubble(localShield)
else -> MessageShieldPosition.InBubble(localShield)
}
}

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)
private 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.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@Composable @Composable
fun TimelineItemGroupedEventsRow( fun TimelineItemGroupedEventsRow(
@ -46,6 +47,7 @@ fun TimelineItemGroupedEventsRow(
focusedEventId: EventId?, focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
@ -72,6 +74,7 @@ fun TimelineItemGroupedEventsRow(
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -95,6 +98,7 @@ private fun TimelineItemGroupedEventsRowContent(
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
@ -127,6 +131,7 @@ private fun TimelineItemGroupedEventsRowContent(
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -168,6 +173,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onShieldClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
@ -192,6 +198,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onShieldClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, 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.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@Composable @Composable
internal fun TimelineItemRow( internal fun TimelineItemRow(
@ -49,6 +50,7 @@ internal fun TimelineItemRow(
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -110,6 +112,7 @@ internal fun TimelineItemRow(
isHighlighted = timelineItem.isEvent(focusedEventId), isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onShieldClick = onShieldClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
@ -132,6 +135,7 @@ internal fun TimelineItemRow(
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,

View file

@ -21,8 +21,6 @@ import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> { class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
@ -104,13 +102,3 @@ fun aTimelineItemStateEventContent(
) = TimelineItemStateEventContent( ) = TimelineItemStateEventContent(
body = body, body = body,
) )
fun aGreyShield() = MessageShield(
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
color = ShieldColor.GREY
)
fun aRedShield() = MessageShield(
message = "Encrypted by a device not verified by its owner.",
color = ShieldColor.RED
)

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

View file

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

View file

@ -101,7 +101,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
originalJson = null, originalJson = null,
latestEditedJson = 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

@ -16,12 +16,32 @@
package io.element.android.libraries.matrix.api.timeline.item.event package io.element.android.libraries.matrix.api.timeline.item.event
data class MessageShield( import androidx.compose.runtime.Immutable
val message: String,
val color: ShieldColor,
)
enum class ShieldColor { @Immutable
RED, sealed interface MessageShield {
GREY /** 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
}
fun MessageShield.isCritical(): Boolean {
return when (this) {
is MessageShield.AuthenticityNotGuaranteed -> isCritical
is MessageShield.UnknownDevice -> isCritical
is MessageShield.UnsignedDevice -> isCritical
is MessageShield.UnverifiedIdentity -> isCritical
is MessageShield.SentInClear -> isCritical
}
} }

View file

@ -27,13 +27,13 @@ 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.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState 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.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@ -135,10 +135,22 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
} }
private fun ShieldState?.map(): MessageShield? { private fun ShieldState?.map(): MessageShield? {
return when (this) { this ?: return null
is ShieldState.Grey -> MessageShield(message = this.message, color = ShieldColor.GREY) val shieldStateCode = when (this) {
is ShieldState.Red -> MessageShield(message = this.message, color = ShieldColor.RED) is ShieldState.Grey -> code
is ShieldState.Red -> code
ShieldState.None -> null
} ?: return null
val isCritical = when (this) {
ShieldState.None, ShieldState.None,
null -> null 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

@ -120,6 +120,7 @@ class KonsistPreviewTest {
"TextComposerVoicePreview", "TextComposerVoicePreview",
"TimelineImageWithCaptionRowPreview", "TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview", "TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview", "TimelineItemEventRowTimestampPreview",
"TimelineItemEventRowWithManyReactionsPreview", "TimelineItemEventRowWithManyReactionsPreview",
"TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithRRPreview",