Merge pull request #3240 from element-hq/feature/valere/message_shields
Timeline UI | MessageShield Support
This commit is contained in:
commit
21f2c5a231
48 changed files with 689 additions and 10 deletions
|
|
@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -121,6 +122,16 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemPollActionList(),
|
||||
),
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent().copy(
|
||||
reactionsState = reactionsState,
|
||||
messageShield = MessageShield.UnknownDevice(isCritical = true)
|
||||
),
|
||||
displayEmojiReactions = true,
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
||||
|
|
@ -181,7 +182,14 @@ private fun SheetContent(
|
|||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
if (target.event.messageShield != null) {
|
||||
MessageShieldView(
|
||||
shield = target.event.messageShield,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline
|
|||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import kotlin.time.Duration
|
||||
|
||||
sealed interface TimelineEvents {
|
||||
|
|
@ -27,6 +28,9 @@ sealed interface TimelineEvents {
|
|||
data object OnFocusEventRender : TimelineEvents
|
||||
data object JumpToLive : TimelineEvents
|
||||
|
||||
data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
|
||||
data object HideShieldDialog : TimelineEvents
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
|
|||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
|
|
@ -97,6 +98,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
|
||||
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
|
|
@ -151,6 +153,8 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
is TimelineEvents.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
TimelineEvents.HideShieldDialog -> messageShield.value = null
|
||||
is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +230,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
focusRequestState = focusRequestState.value,
|
||||
messageShield = messageShield.value,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ data class TimelineState(
|
|||
val newEventState: NewEventState,
|
||||
val isLive: Boolean,
|
||||
val focusRequestState: FocusRequestState,
|
||||
// If not null, info will be rendered in a dialog
|
||||
val messageShield: MessageShield?,
|
||||
val eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -50,6 +51,7 @@ fun aTimelineState(
|
|||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
focusedEventIndex: Int = -1,
|
||||
isLive: Boolean = true,
|
||||
messageShield: MessageShield? = null,
|
||||
eventSink: (TimelineEvents) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
|
|
@ -65,6 +67,7 @@ fun aTimelineState(
|
|||
newEventState = NewEventState.None,
|
||||
isLive = isLive,
|
||||
focusRequestState = focusRequestState,
|
||||
messageShield = messageShield,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -138,6 +141,7 @@ internal fun aTimelineItemEvent(
|
|||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
|
||||
messageShield: MessageShield? = null,
|
||||
): TimelineItem.Event {
|
||||
return TimelineItem.Event(
|
||||
id = UUID.randomUUID().toString(),
|
||||
|
|
@ -161,7 +165,8 @@ internal fun aTimelineItemEvent(
|
|||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
isThreaded = isThreaded,
|
||||
origin = null
|
||||
origin = null,
|
||||
messageShield = messageShield,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.toText
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
|
|
@ -68,12 +69,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationView
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
|
@ -124,6 +127,10 @@ fun TimelineView(
|
|||
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
fun onShieldClick(shield: MessageShield) {
|
||||
state.eventSink(TimelineEvents.ShowShieldDialog(shield))
|
||||
}
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
AnimatedVisibility(visible = true, enter = fadeIn()) {
|
||||
Box(modifier) {
|
||||
|
|
@ -154,6 +161,7 @@ fun TimelineView(
|
|||
focusedEventId = state.focusedEventId,
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
onShieldClick = ::onShieldClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
|
|
@ -186,6 +194,17 @@ fun TimelineView(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
MessageShieldDialog(state)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageShieldDialog(state: TimelineState) {
|
||||
val messageShield = state.messageShield ?: return
|
||||
AlertDialog(
|
||||
content = messageShield.toText(),
|
||||
onDismiss = { state.eventSink.invoke(TimelineEvents.HideShieldDialog) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ internal fun ATimelineItemEventRow(
|
|||
isHighlighted = isHighlighted,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onShieldClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
inReplyToClick = {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -33,26 +34,31 @@ import io.element.android.compound.theme.ElementTheme
|
|||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.isEdited
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun TimelineEventTimestampView(
|
||||
event: TimelineItem.Event,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formattedTime = event.sentTime
|
||||
val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable
|
||||
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
|
||||
val isMessageEdited = event.content.isEdited()
|
||||
val tint = if (hasUnrecoverableError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
|
||||
val tint = if (hasUnrecoverableError || hasEncryptionCritical) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isMessageEdited) {
|
||||
|
|
@ -77,13 +83,28 @@ fun TimelineEventTimestampView(
|
|||
modifier = Modifier.size(15.dp, 18.dp),
|
||||
)
|
||||
}
|
||||
event.messageShield?.let { shield ->
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = shield.toIcon(),
|
||||
contentDescription = shield.toText(),
|
||||
modifier = Modifier
|
||||
.size(15.dp)
|
||||
.clickable { onShieldClick(shield) },
|
||||
tint = shield.toIconColor(),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
|
||||
TimelineEventTimestampView(event = event)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
object TimelineEventTimestampViewDefaults {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
|
||||
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
|
||||
override val values: Sequence<TimelineItem.Event>
|
||||
|
|
@ -37,5 +38,11 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
|
|||
localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
|
||||
content = aTimelineItemTextContent().copy(isEdited = true),
|
||||
),
|
||||
aTimelineItemEvent().copy(
|
||||
messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false),
|
||||
),
|
||||
aTimelineItemEvent().copy(
|
||||
messageShield = MessageShield.UnknownDevice(isCritical = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
|
||||
|
|
@ -118,6 +119,7 @@ fun TimelineItemEventRow(
|
|||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
|
|
@ -180,6 +182,7 @@ fun TimelineItemEventRow(
|
|||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
|
|
@ -198,6 +201,7 @@ fun TimelineItemEventRow(
|
|||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
|
|
@ -253,6 +257,7 @@ private fun TimelineItemEventRowContent(
|
|||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onUserDataClick: () -> Unit,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
|
|
@ -320,6 +325,7 @@ private fun TimelineItemEventRowContent(
|
|||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onLinkClick = onLinkClick,
|
||||
|
|
@ -380,6 +386,7 @@ private fun MessageSenderInformation(
|
|||
@Composable
|
||||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
|
|
@ -420,6 +427,7 @@ private fun MessageEventBubbleContent(
|
|||
@Composable
|
||||
fun WithTimestampLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
canShrinkContent: Boolean = false,
|
||||
content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
|
|
@ -430,6 +438,7 @@ private fun MessageEventBubbleContent(
|
|||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
// Outer padding
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
|
|
@ -450,6 +459,7 @@ private fun MessageEventBubbleContent(
|
|||
overlay = {
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
|
|
@ -460,6 +470,7 @@ private fun MessageEventBubbleContent(
|
|||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
|
@ -507,6 +518,7 @@ private fun MessageEventBubbleContent(
|
|||
val contentWithTimestamp = @Composable {
|
||||
WithTimestampLayout(
|
||||
timestampPosition = timestampPosition,
|
||||
onShieldClick = onShieldClick,
|
||||
canShrinkContent = canShrinkContent,
|
||||
modifier = timestampLayoutModifier,
|
||||
) { onContentLayoutChange ->
|
||||
|
|
@ -519,6 +531,7 @@ private fun MessageEventBubbleContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
val inReplyToModifier = Modifier
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
|
||||
@Composable
|
||||
fun TimelineItemGroupedEventsRow(
|
||||
|
|
@ -46,6 +47,7 @@ fun TimelineItemGroupedEventsRow(
|
|||
focusedEventId: EventId?,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
|
|
@ -72,6 +74,7 @@ fun TimelineItemGroupedEventsRow(
|
|||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
|
|
@ -95,6 +98,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
isLastOutgoingMessage: Boolean,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
|
|
@ -127,6 +131,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
|
|
@ -168,6 +173,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
|
|||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onShieldClick = {},
|
||||
inReplyToClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
|
|
@ -192,6 +198,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
|||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onShieldClick = {},
|
||||
inReplyToClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.text.toPx
|
|||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
|
||||
@Composable
|
||||
internal fun TimelineItemRow(
|
||||
|
|
@ -49,6 +50,7 @@ internal fun TimelineItemRow(
|
|||
onLinkClick: (String) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
|
|
@ -110,6 +112,7 @@ internal fun TimelineItemRow(
|
|||
isHighlighted = timelineItem.isEvent(focusedEventId),
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onShieldClick = onShieldClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
|
|
@ -132,6 +135,7 @@ internal fun TimelineItemRow(
|
|||
focusedEventId = focusedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
isThreaded = currentTimelineItem.event.isThreaded(),
|
||||
debugInfo = currentTimelineItem.event.debugInfo,
|
||||
origin = currentTimelineItem.event.origin,
|
||||
messageShield = currentTimelineItem.event.messageShield,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
|
|
@ -83,6 +84,7 @@ sealed interface TimelineItem {
|
|||
val isThreaded: Boolean,
|
||||
val debugInfo: TimelineItemDebugInfo,
|
||||
val origin: TimelineItemEventOrigin?,
|
||||
val messageShield: MessageShield?,
|
||||
) : TimelineItem {
|
||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
|
|
@ -48,6 +49,7 @@ internal fun aMessageEvent(
|
|||
isThreaded: Boolean = false,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
messageShield: MessageShield? = null,
|
||||
) = TimelineItem.Event(
|
||||
id = eventId?.value.orEmpty(),
|
||||
eventId = eventId,
|
||||
|
|
@ -66,5 +68,6 @@ internal fun aMessageEvent(
|
|||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
isThreaded = isThreaded,
|
||||
origin = null
|
||||
origin = null,
|
||||
messageShield = messageShield,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
@ -591,6 +592,26 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show shield hide shield`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val shield = aCriticalShield()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.messageShield).isNull()
|
||||
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.messageShield).isEqualTo(shield)
|
||||
state.eventSink(TimelineEvents.HideShieldDialog)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.messageShield).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||
val timeline = FakeTimeline(
|
||||
|
|
|
|||
|
|
@ -22,17 +22,21 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -97,6 +101,47 @@ class TimelineViewTest {
|
|||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show shield dialog`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf<TimelineItem>(
|
||||
aTimelineItemEvent(
|
||||
// Do not use a Text because EditorStyledText cannot be used in UI test.
|
||||
content = aTimelineItemImageContent(),
|
||||
messageShield = MessageShield.UnverifiedIdentity(true),
|
||||
),
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
TimelineEvents.OnScrollFinished(0),
|
||||
TimelineEvents.OnScrollFinished(0),
|
||||
TimelineEvents.OnScrollFinished(0),
|
||||
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hide shield dialog`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
messageShield = aCriticalShield(),
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ class TimelineItemGrouperTest {
|
|||
inReplyTo = null,
|
||||
isThreaded = false,
|
||||
debugInfo = aTimelineItemDebugInfo(),
|
||||
origin = null
|
||||
origin = null,
|
||||
messageShield = null,
|
||||
)
|
||||
private val aNonGroupableItem = aMessageEvent()
|
||||
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
|||
originalJson = null,
|
||||
latestEditedJson = null
|
||||
),
|
||||
origin = null
|
||||
origin = null,
|
||||
messageShield = null,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ data class EventTimelineItem(
|
|||
val content: EventContent,
|
||||
val debugInfo: TimelineItemDebugInfo,
|
||||
val origin: TimelineItemEventOrigin?,
|
||||
val messageShield: MessageShield?,
|
||||
) {
|
||||
fun inReplyTo(): InReplyTo? {
|
||||
return (content as? MessageContent)?.inReplyTo
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
|
|
@ -31,6 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.matrix.rustcomponents.sdk.Reaction
|
||||
import org.matrix.rustcomponents.sdk.ShieldState
|
||||
import uniffi.matrix_sdk_common.ShieldStateCode
|
||||
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
|
||||
|
|
@ -56,7 +59,8 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
|||
timestamp = it.timestamp().toLong(),
|
||||
content = contentMapper.map(it.content()),
|
||||
debugInfo = it.debugInfo().map(),
|
||||
origin = it.origin()?.map()
|
||||
origin = it.origin()?.map(),
|
||||
messageShield = it.getShield(false)?.map(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -129,3 +133,24 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
|
|||
RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION
|
||||
}
|
||||
}
|
||||
|
||||
private fun ShieldState?.map(): MessageShield? {
|
||||
this ?: return null
|
||||
val shieldStateCode = when (this) {
|
||||
is ShieldState.Grey -> code
|
||||
is ShieldState.Red -> code
|
||||
ShieldState.None -> null
|
||||
} ?: return null
|
||||
val isCritical = when (this) {
|
||||
ShieldState.None,
|
||||
is ShieldState.Grey -> false
|
||||
is ShieldState.Red -> true
|
||||
}
|
||||
return when (shieldStateCode) {
|
||||
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
|
||||
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
|
||||
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
|
||||
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
|
||||
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||
|
|
@ -58,6 +59,7 @@ fun anEventTimelineItem(
|
|||
timestamp: Long = 0L,
|
||||
content: EventContent = aProfileChangeMessageContent(),
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
messageShield: MessageShield? = null,
|
||||
) = EventTimelineItem(
|
||||
eventId = eventId,
|
||||
transactionId = transactionId,
|
||||
|
|
@ -75,6 +77,7 @@ fun anEventTimelineItem(
|
|||
content = content,
|
||||
debugInfo = debugInfo,
|
||||
origin = null,
|
||||
messageShield = messageShield,
|
||||
)
|
||||
|
||||
fun aProfileTimelineDetails(
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ class KonsistPreviewTest {
|
|||
"TextComposerVoicePreview",
|
||||
"TimelineImageWithCaptionRowPreview",
|
||||
"TimelineItemEventRowForDirectRoomPreview",
|
||||
"TimelineItemEventRowShieldPreview",
|
||||
"TimelineItemEventRowTimestampPreview",
|
||||
"TimelineItemEventRowWithManyReactionsPreview",
|
||||
"TimelineItemEventRowWithRRPreview",
|
||||
|
|
@ -128,6 +129,7 @@ class KonsistPreviewTest {
|
|||
"TimelineItemGroupedEventsRowContentExpandedPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"UserAvatarColorsPreview",
|
||||
)
|
||||
.assertTrue(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfd9526209bdb36863765802036b08a4a61ae8035afed4f47b262d521d8bd37d
|
||||
size 45145
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9b82542b62c8a35c59f96c1b348a62f71de0d1302550458f11581ddec65b2172
|
||||
size 44342
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:18808ed71ef0cbc10be0eda977ab077a0eaa83afbfd2ae50c500b802aa4c7976
|
||||
size 30901
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1d0ad0c7f690d07d67a13fcad3ee8901a77602affe063d2f8222345de3aff934
|
||||
size 30039
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6d37081422eb655aaaa58834912625efd07f61eddcb7117f32b8edb9512969ab
|
||||
size 5052
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f45afae6e96d3cddb50eda9acac94ba7b556e480eccf56e5f7c4c17a7a5bfb7
|
||||
size 5009
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6dcce84695624b516035b2a3fe33ad71bf0b278a769ac013ad7c9b4feae958c5
|
||||
size 5028
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7e07f82770e48bcd04bf5532dd353d0f84d6128911e692a8e728f1b768d0b947
|
||||
size 4988
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4ba97a7a22e790d2708364c32035d8428677deaa12ff62c0304d252a1043f7a
|
||||
size 149223
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:007de1b45bde33d6a7a8ea389a1b80a07f0696878ecfe34cbcf12c74b866ed09
|
||||
size 148817
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:99acfb92911fbc7b60126cdadbd5b373f9692258d4f6c59a8167425e7b5603b5
|
||||
size 30870
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1381e2fd76dbb52a67fb12d588c839b92a5701c2abf3d2fc14d89fead977a35e
|
||||
size 30727
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7348bb4cc9cde7eda1c7f65ce5f997838add5d8a84974b13bcf6685b15c80f1a
|
||||
size 31136
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07036aaecb01e2b27e133a2d9f254ef38ff8ab18913a2684db1391aa829bd3ed
|
||||
size 30945
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71f9d463136dd01c40bd11d5c4ea571ce65dd4c42f0c724e8c6c4ffec3eb6fa6
|
||||
size 37341
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b1cee48ed80d4a6318e2b1b04873f1f59cab81f7f18046d3d4d31dc27eb26544
|
||||
size 35166
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be622097026463273020202508a7990f358b5eb33103917be2bc272e744d7a76
|
||||
size 11310
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c01359ad5875334bc4904a3cfb171f8d4cc87e6452591a72435c8b3f116439ac
|
||||
size 8398
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e47308ddbd1310aadf5471dbe7c1ba3d22e26555d3f08514ea13b4acc3cf07b8
|
||||
size 7048
|
||||
Loading…
Add table
Add a link
Reference in a new issue