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
commit c420fcad18
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.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

@ -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.core.UserId
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.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -50,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
@ -65,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,
) )
} }
@ -138,6 +141,7 @@ internal fun aTimelineItemEvent(
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(), readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
messageShield: MessageShield? = null,
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@ -161,7 +165,8 @@ internal fun aTimelineItemEvent(
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,
isThreaded = isThreaded, 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.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

@ -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, isHighlighted = isHighlighted,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onShieldClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
inReplyToClick = {}, 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 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 = shield.toText(),
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

@ -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.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
@ -118,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,
@ -180,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) },
@ -198,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) },
@ -253,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,
@ -320,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,
@ -380,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,
@ -420,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,
@ -430,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)
@ -450,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)
) )
@ -460,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)
@ -507,6 +518,7 @@ 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 ->
@ -519,6 +531,7 @@ private fun MessageEventBubbleContent(
) )
} }
} }
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

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.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

@ -86,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
isThreaded = currentTimelineItem.event.isThreaded(), isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo, debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin, 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.core.UserId
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.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.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
@ -83,6 +84,7 @@ sealed interface TimelineItem {
val isThreaded: Boolean, val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo, val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?, val origin: TimelineItemEventOrigin?,
val messageShield: MessageShield?,
) : TimelineItem { ) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine 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.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

@ -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.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent 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.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.factories.TimelineItemsFactory
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
@ -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 @Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeTimeline( 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.onNodeWithContentDescription
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 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.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.timeline.model.virtual.TimelineItemLoadingIndicatorModel
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.aTypingNotificationState 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.core.UserId
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 io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -97,6 +101,47 @@ class TimelineViewTest {
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(TimelineEvents.JumpToLive) 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( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(

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

@ -39,6 +39,7 @@ data class EventTimelineItem(
val content: EventContent, val content: EventContent,
val debugInfo: TimelineItemDebugInfo, val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?, val origin: TimelineItemEventOrigin?,
val messageShield: MessageShield?,
) { ) {
fun inReplyTo(): InReplyTo? { fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.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.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem 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.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.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
@ -31,6 +32,8 @@ 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 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
@ -56,7 +59,8 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
timestamp = it.timestamp().toLong(), timestamp = it.timestamp().toLong(),
content = contentMapper.map(it.content()), content = contentMapper.map(it.content()),
debugInfo = it.debugInfo().map(), 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 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.InReplyTo
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.MessageContent 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.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@ -58,6 +59,7 @@ fun anEventTimelineItem(
timestamp: Long = 0L, timestamp: Long = 0L,
content: EventContent = aProfileChangeMessageContent(), content: EventContent = aProfileChangeMessageContent(),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
messageShield: MessageShield? = null,
) = EventTimelineItem( ) = EventTimelineItem(
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
@ -75,6 +77,7 @@ fun anEventTimelineItem(
content = content, content = content,
debugInfo = debugInfo, debugInfo = debugInfo,
origin = null, origin = null,
messageShield = messageShield,
) )
fun aProfileTimelineDetails( fun aProfileTimelineDetails(

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dfd9526209bdb36863765802036b08a4a61ae8035afed4f47b262d521d8bd37d
size 45145

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b82542b62c8a35c59f96c1b348a62f71de0d1302550458f11581ddec65b2172
size 44342

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18808ed71ef0cbc10be0eda977ab077a0eaa83afbfd2ae50c500b802aa4c7976
size 30901

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d0ad0c7f690d07d67a13fcad3ee8901a77602affe063d2f8222345de3aff934
size 30039

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d37081422eb655aaaa58834912625efd07f61eddcb7117f32b8edb9512969ab
size 5052

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f45afae6e96d3cddb50eda9acac94ba7b556e480eccf56e5f7c4c17a7a5bfb7
size 5009

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6dcce84695624b516035b2a3fe33ad71bf0b278a769ac013ad7c9b4feae958c5
size 5028

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e07f82770e48bcd04bf5532dd353d0f84d6128911e692a8e728f1b768d0b947
size 4988

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4ba97a7a22e790d2708364c32035d8428677deaa12ff62c0304d252a1043f7a
size 149223

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:007de1b45bde33d6a7a8ea389a1b80a07f0696878ecfe34cbcf12c74b866ed09
size 148817

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99acfb92911fbc7b60126cdadbd5b373f9692258d4f6c59a8167425e7b5603b5
size 30870

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1381e2fd76dbb52a67fb12d588c839b92a5701c2abf3d2fc14d89fead977a35e
size 30727

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7348bb4cc9cde7eda1c7f65ce5f997838add5d8a84974b13bcf6685b15c80f1a
size 31136

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07036aaecb01e2b27e133a2d9f254ef38ff8ab18913a2684db1391aa829bd3ed
size 30945

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71f9d463136dd01c40bd11d5c4ea571ce65dd4c42f0c724e8c6c4ffec3eb6fa6
size 37341

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1cee48ed80d4a6318e2b1b04873f1f59cab81f7f18046d3d4d31dc27eb26544
size 35166

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:be622097026463273020202508a7990f358b5eb33103917be2bc272e744d7a76
size 11310

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c01359ad5875334bc4904a3cfb171f8d4cc87e6452591a72435c8b3f116439ac
size 8398

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e47308ddbd1310aadf5471dbe7c1ba3d22e26555d3f08514ea13b4acc3cf07b8
size 7048