diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index f6b58e2799..e386efc781 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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 @@ -137,6 +138,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(), @@ -159,7 +161,8 @@ internal fun aTimelineItemEvent( inReplyTo = inReplyTo, debugInfo = debugInfo, isThreaded = isThreaded, - origin = null + origin = null, + messageShield = messageShield, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldPosition.kt new file mode 100644 index 0000000000..41c424c56e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldPosition.kt @@ -0,0 +1,25 @@ +/* + * 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() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt new file mode 100644 index 0000000000..decb75fb2e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt @@ -0,0 +1,120 @@ +/* + * 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.background +import androidx.compose.foundation.border +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.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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.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.ShieldColor + +@Composable +internal fun MessageShieldView( + isMine: Boolean = false, + shield: MessageShield, + 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( + verticalAlignment = Alignment.Top, + modifier = modifier + .background(backgroundBubbleColor, RoundedCornerShape(8.dp)) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + Icon( + imageVector = shield.toIcon(), + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = iconColor, + ) + Spacer(modifier = Modifier.size(4.dp)) + val textColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary + Text( + text = shield.message, + style = ElementTheme.typography.fontBodyXsRegular, + color = textColor + ) + } +} + +@Composable +private fun MessageShield.toIcon(): ImageVector { + return when (this.color) { + ShieldColor.RED -> CompoundIcons.Error() + ShieldColor.GREY -> CompoundIcons.InfoSolid() + } +} + +@PreviewsDayNight +@Composable +internal fun MessageShieldViewPreviews() { + ElementPreview { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + MessageShieldView( + shield = MessageShield( + message = "The authenticity of this encrypted message can't be guaranteed on this device.", + color = ShieldColor.GREY + ) + ) + MessageShieldView( + isMine = true, + shield = MessageShield( + message = "The authenticity of this encrypted message can't be guaranteed on this device.", + color = ShieldColor.GREY + ) + ) + MessageShieldView( + shield = MessageShield( + message = "Encrypted by a device not verified by its owner.", + color = ShieldColor.RED + ) + ) + MessageShieldView( + shield = MessageShield( + message = "Encrypted by an unknown or deleted device.", + color = ShieldColor.RED + ) + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index a2ac0ac7b1..bc567c1250 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.components import android.annotation.SuppressLint +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation @@ -50,6 +51,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -75,6 +77,8 @@ 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.TimelineItemVideoContent 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.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo @@ -277,6 +281,7 @@ private fun TimelineItemEventRowContent( val ( sender, message, + shield, reactions, ) = createRefs() @@ -328,6 +333,29 @@ 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 if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactionsView( @@ -339,7 +367,11 @@ private fun TimelineItemEventRowContent( onMoreReactionsClick = { onMoreReactionsClick(event) }, modifier = Modifier .constrainAs(reactions) { - top.linkTo(message.bottom, margin = (-4).dp) + if (shieldPosition is MessageShieldPosition.OutOfBubble) { + top.linkTo(shield.bottom, margin = (-4).dp) + } else { + top.linkTo(message.bottom, margin = (-4).dp) + } linkStartOrEnd(event) } .zIndex(1f) @@ -472,6 +504,7 @@ private fun MessageEventBubbleContent( @Composable fun CommonLayout( timestampPosition: TimestampPosition, + messageShieldPosition: MessageShieldPosition, showThreadDecoration: Boolean, inReplyToDetails: InReplyToDetails?, modifier: Modifier = Modifier, @@ -510,13 +543,30 @@ private fun MessageEventBubbleContent( canShrinkContent = canShrinkContent, modifier = timestampLayoutModifier, ) { onContentLayoutChange -> - TimelineItemEventContentView( - content = event.content, - onLinkClick = onLinkClick, - eventSink = eventSink, - onContentLayoutChange = onContentLayoutChange, - modifier = contentModifier - ) + + if (messageShieldPosition is MessageShieldPosition.InBubble) { + Column { + TimelineItemEventContentView( + content = event.content, + onLinkClick = onLinkClick, + 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 -> @@ -551,9 +601,11 @@ private fun MessageEventBubbleContent( is TimelineItemPollContent -> TimestampPosition.Below else -> TimestampPosition.Default } + val messageShieldPosition = event.shieldPosition() CommonLayout( showThreadDecoration = event.isThreaded, - timestampPosition = timestampPosition, + messageShieldPosition = messageShieldPosition, + timestampPosition = if (messageShieldPosition is MessageShieldPosition.InBubble) TimestampPosition.Below else timestampPosition, inReplyToDetails = event.inReplyTo, canShrinkContent = event.content is TimelineItemVoiceContent, modifier = bubbleModifier.semantics(mergeDescendants = true) { @@ -590,3 +642,68 @@ 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() + ), + ) + } +} + +private fun TimelineItem.Event.shieldPosition(): MessageShieldPosition { + if (this.messageShield == null) return MessageShieldPosition.None + return when (this.content) { + is TimelineItemImageContent, + is TimelineItemVideoContent, + is TimelineItemStickerContent, + is TimelineItemLocationContent, + is TimelineItemPollContent -> MessageShieldPosition.OutOfBubble(this.messageShield) + else -> MessageShieldPosition.InBubble(this.messageShield) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index c5d303b3f1..ae2dd3e39c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor( isThreaded = currentTimelineItem.event.isThreaded(), debugInfo = currentTimelineItem.event.debugInfo, origin = currentTimelineItem.event.origin, + messageShield = currentTimelineItem.event.messageShield, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index f77db70506..73bfac5a9d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -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 @@ -82,6 +83,7 @@ sealed interface TimelineItem { val isThreaded: Boolean, val debugInfo: TimelineItemDebugInfo, val origin: TimelineItemEventOrigin?, + val messageShield: MessageShield?, ) : TimelineItem { val showSenderInformation = groupPosition.isNew() && !isMine diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 29fa048e1f..e103c44d1c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -21,6 +21,8 @@ import android.text.style.StyleSpan import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.text.buildSpannedString 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 class TimelineItemEventContentProvider : PreviewParameterProvider { @@ -102,3 +104,13 @@ fun aTimelineItemStateEventContent( ) = TimelineItemStateEventContent( 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 +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index fa15f8f096..170fedfa28 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -38,6 +38,7 @@ data class EventTimelineItem( val content: EventContent, val debugInfo: TimelineItemDebugInfo, val origin: TimelineItemEventOrigin?, + val messageShield: MessageShield?, ) { fun inReplyTo(): InReplyTo? { return (content as? MessageContent)?.inReplyTo diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt new file mode 100644 index 0000000000..58fc96d8b1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 + +data class MessageShield( + val message: String, + val color: ShieldColor, +) + +enum class ShieldColor { + RED, + GREY +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index dd0bdabd7f..f80b877baa 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -23,14 +23,17 @@ 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 +import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin 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 org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo @@ -55,7 +58,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(), ) } } @@ -128,3 +132,12 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin { RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION } } + +private fun ShieldState?.map(): MessageShield? { + return when (this) { + is ShieldState.Grey -> MessageShield(message = this.message, color = ShieldColor.GREY) + is ShieldState.Red -> MessageShield(message = this.message, color = ShieldColor.RED) + ShieldState.None, + null -> null + } +}