Iterate on shield mapping and rendering
Also handle click on the timeline and information displayed on long click.
This commit is contained in:
committed by
Benoit Marty
parent
5dceba8146
commit
34268a30ea
@@ -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 }
|
||||
|
||||
@@ -51,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
|
||||
@@ -66,6 +67,7 @@ fun aTimelineState(
|
||||
newEventState = NewEventState.None,
|
||||
isLive = isLive,
|
||||
focusRequestState = focusRequestState,
|
||||
messageShield = messageShield,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ internal fun ATimelineItemEventRow(
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onShieldClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
inReplyToClick = {},
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
|
||||
sealed class MessageShieldPosition {
|
||||
data class InBubble(val messageShield: MessageShield) : MessageShieldPosition()
|
||||
data class OutOfBubble(val messageShield: MessageShield) : MessageShieldPosition()
|
||||
object None : MessageShieldPosition()
|
||||
}
|
||||
@@ -16,19 +16,18 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.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
|
||||
@@ -36,52 +35,71 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
||||
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun MessageShieldView(
|
||||
isMine: Boolean = false,
|
||||
shield: MessageShield,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val borderColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.borderCriticalPrimary else ElementTheme.colors.bgSubtlePrimary
|
||||
val iconColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary
|
||||
|
||||
val backgroundBubbleColor = when {
|
||||
isMine -> ElementTheme.colors.messageFromMeBackground
|
||||
else -> ElementTheme.colors.messageFromOtherBackground
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = modifier
|
||||
.background(backgroundBubbleColor, RoundedCornerShape(8.dp))
|
||||
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
||||
.padding(8.dp)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = shield.toIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = iconColor,
|
||||
tint = shield.toIconColor(),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
val textColor = if (shield.color == ShieldColor.RED) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
|
||||
Text(
|
||||
text = shield.message,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = textColor
|
||||
text = shield.toText(),
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = shield.toTextColor()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageShield.toIcon(): ImageVector {
|
||||
return when (this.color) {
|
||||
ShieldColor.RED -> CompoundIcons.Error()
|
||||
ShieldColor.GREY -> CompoundIcons.InfoSolid()
|
||||
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,
|
||||
is MessageShield.UnverifiedIdentity -> CompoundIcons.Admin()
|
||||
is MessageShield.UnknownDevice,
|
||||
is MessageShield.UnsignedDevice -> CompoundIcons.HelpSolid()
|
||||
is MessageShield.SentInClear -> CompoundIcons.KeyOff()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,31 +107,24 @@ private fun MessageShield.toIcon(): ImageVector {
|
||||
@Composable
|
||||
internal fun MessageShieldViewPreview() {
|
||||
ElementPreview {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
MessageShieldView(
|
||||
shield = MessageShield(
|
||||
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
color = ShieldColor.GREY
|
||||
)
|
||||
shield = MessageShield.UnknownDevice(true)
|
||||
)
|
||||
MessageShieldView(
|
||||
isMine = true,
|
||||
shield = MessageShield(
|
||||
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
color = ShieldColor.GREY
|
||||
)
|
||||
shield = MessageShield.UnverifiedIdentity(true)
|
||||
)
|
||||
MessageShieldView(
|
||||
shield = MessageShield(
|
||||
message = "Encrypted by a device not verified by its owner.",
|
||||
color = ShieldColor.RED
|
||||
)
|
||||
shield = MessageShield.AuthenticityNotGuaranteed(false)
|
||||
)
|
||||
MessageShieldView(
|
||||
shield = MessageShield(
|
||||
message = "Encrypted by an unknown or deleted device.",
|
||||
color = ShieldColor.RED
|
||||
)
|
||||
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 = null,
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
@@ -37,7 +36,6 @@ import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -52,7 +50,6 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.invisibleToUser
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTag
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -78,8 +75,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aGreyShield
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aRedShield
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
@@ -95,6 +90,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.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
|
||||
@@ -123,6 +119,7 @@ fun TimelineItemEventRow(
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
@@ -185,6 +182,7 @@ fun TimelineItemEventRow(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
@@ -203,6 +201,7 @@ fun TimelineItemEventRow(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onShieldClick = onShieldClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
@@ -258,6 +257,7 @@ private fun TimelineItemEventRowContent(
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onUserDataClick: () -> Unit,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
@@ -281,7 +281,6 @@ private fun TimelineItemEventRowContent(
|
||||
val (
|
||||
sender,
|
||||
message,
|
||||
shield,
|
||||
reactions,
|
||||
) = createRefs()
|
||||
|
||||
@@ -326,6 +325,7 @@ private fun TimelineItemEventRowContent(
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onLinkClick = onLinkClick,
|
||||
@@ -333,29 +333,6 @@ private fun TimelineItemEventRowContent(
|
||||
)
|
||||
}
|
||||
|
||||
val shieldPosition = event.shieldPosition()
|
||||
if (shieldPosition is MessageShieldPosition.OutOfBubble) {
|
||||
MessageShieldView(
|
||||
isMine = event.isMine,
|
||||
shield = shieldPosition.messageShield,
|
||||
modifier = Modifier
|
||||
.constrainAs(shield) {
|
||||
top.linkTo(message.bottom, margin = (-4).dp)
|
||||
linkStartOrEnd(event)
|
||||
}
|
||||
.padding(
|
||||
// Note: due to the applied constraints, start is left for other's message and right for mine
|
||||
// In design we want a offset of 6.dp compare to the bubble, so start is 22.dp (16 + 6)
|
||||
start = when {
|
||||
event.isMine -> 22.dp
|
||||
timelineRoomInfo.isDm -> 22.dp
|
||||
else -> 22.dp + BUBBLE_INCOMING_OFFSET
|
||||
},
|
||||
end = 16.dp
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactionsView(
|
||||
@@ -367,11 +344,7 @@ private fun TimelineItemEventRowContent(
|
||||
onMoreReactionsClick = { onMoreReactionsClick(event) },
|
||||
modifier = Modifier
|
||||
.constrainAs(reactions) {
|
||||
if (shieldPosition is MessageShieldPosition.OutOfBubble) {
|
||||
top.linkTo(shield.bottom, margin = (-4).dp)
|
||||
} else {
|
||||
top.linkTo(message.bottom, margin = (-4).dp)
|
||||
}
|
||||
top.linkTo(message.bottom, margin = (-4).dp)
|
||||
linkStartOrEnd(event)
|
||||
}
|
||||
.zIndex(1f)
|
||||
@@ -413,6 +386,7 @@ private fun MessageSenderInformation(
|
||||
@Composable
|
||||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
onShieldClick: (MessageShield) -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
@@ -453,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,
|
||||
@@ -463,6 +438,7 @@ private fun MessageEventBubbleContent(
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
// Outer padding
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
@@ -483,6 +459,7 @@ private fun MessageEventBubbleContent(
|
||||
overlay = {
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
@@ -493,6 +470,7 @@ private fun MessageEventBubbleContent(
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onShieldClick = onShieldClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
@@ -505,7 +483,6 @@ private fun MessageEventBubbleContent(
|
||||
@Composable
|
||||
fun CommonLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
messageShieldPosition: MessageShieldPosition,
|
||||
showThreadDecoration: Boolean,
|
||||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -541,35 +518,20 @@ private fun MessageEventBubbleContent(
|
||||
val contentWithTimestamp = @Composable {
|
||||
WithTimestampLayout(
|
||||
timestampPosition = timestampPosition,
|
||||
onShieldClick = onShieldClick,
|
||||
canShrinkContent = canShrinkContent,
|
||||
modifier = timestampLayoutModifier,
|
||||
) { onContentLayoutChange ->
|
||||
|
||||
if (messageShieldPosition is MessageShieldPosition.InBubble) {
|
||||
Column {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = contentModifier
|
||||
)
|
||||
MessageShieldView(
|
||||
modifier = Modifier.padding(start = 8.dp, end = 8.dp),
|
||||
shield = messageShieldPosition.messageShield,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = contentModifier
|
||||
)
|
||||
}
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = contentModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
val inReplyToModifier = Modifier
|
||||
@@ -602,11 +564,9 @@ private fun MessageEventBubbleContent(
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
val messageShieldPosition = event.shieldPosition()
|
||||
CommonLayout(
|
||||
showThreadDecoration = event.isThreaded,
|
||||
messageShieldPosition = messageShieldPosition,
|
||||
timestampPosition = if (messageShieldPosition is MessageShieldPosition.InBubble) TimestampPosition.Below else timestampPosition,
|
||||
timestampPosition = timestampPosition,
|
||||
inReplyToDetails = event.inReplyTo,
|
||||
canShrinkContent = event.content is TimelineItemVoiceContent,
|
||||
modifier = bubbleModifier.semantics(mergeDescendants = true) {
|
||||
@@ -643,83 +603,3 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
name = "Encryption Shields"
|
||||
)
|
||||
@Preview(
|
||||
name = "Encryption Shields - Night",
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES
|
||||
)
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowShieldsPreview() = ElementPreview {
|
||||
Column {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
senderDisplayName = "Sender with a super long name that should ellipsize",
|
||||
isMine = true,
|
||||
content = aTimelineItemTextContent(
|
||||
body = "Message sent from unsigned device"
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
messageShield = aRedShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
senderDisplayName = "Sender with a super long name that should ellipsize",
|
||||
content = aTimelineItemTextContent(
|
||||
body = "Short Message with authenticity warning"
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
messageShield = aGreyShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
messageShield = aRedShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
messageShield = aGreyShield()
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun TimelineItem.Event.shieldPosition(): MessageShieldPosition {
|
||||
val shield = this.messageShield ?: return MessageShieldPosition.None
|
||||
|
||||
// sdk returns raw human readable strings, add i18n support
|
||||
val localizedMessage = when (shield.message) {
|
||||
"The authenticity of this encrypted message can't be guaranteed on this device." -> stringResource(
|
||||
CommonStrings.event_shield_reason_authenticity_not_guaranteed
|
||||
)
|
||||
"Encrypted by a device not verified by its owner." -> stringResource(CommonStrings.event_shield_reason_unsigned_device)
|
||||
"Encrypted by an unknown or deleted device." -> stringResource(CommonStrings.event_shield_reason_unknown_device)
|
||||
"Encrypted by an unverified user." -> stringResource(CommonStrings.event_shield_reason_unverified_identity)
|
||||
else -> shield.message
|
||||
}
|
||||
val localShield = shield.copy(message = localizedMessage)
|
||||
|
||||
return when (this.content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemPollContent -> MessageShieldPosition.OutOfBubble(localShield)
|
||||
else -> MessageShieldPosition.InBubble(localShield)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowShieldPreview() = ElementPreview {
|
||||
Column {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
senderDisplayName = "Sender with a super long name that should ellipsize",
|
||||
isMine = true,
|
||||
content = aTimelineItemTextContent(
|
||||
body = "Message sent from unsigned device"
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
messageShield = aCriticalShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
senderDisplayName = "Sender with a super long name that should ellipsize",
|
||||
content = aTimelineItemTextContent(
|
||||
body = "Short Message with authenticity warning"
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
messageShield = aWarningShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
messageShield = aCriticalShield()
|
||||
),
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
messageShield = aWarningShield()
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aWarningShield() = MessageShield.AuthenticityNotGuaranteed(isCritical = false)
|
||||
|
||||
private fun aCriticalShield() = MessageShield.UnverifiedIdentity(isCritical = true)
|
||||
@@ -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,
|
||||
|
||||
@@ -21,8 +21,6 @@ import android.text.style.StyleSpan
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
|
||||
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||
@@ -104,13 +102,3 @@ fun aTimelineItemStateEventContent(
|
||||
) = TimelineItemStateEventContent(
|
||||
body = body,
|
||||
)
|
||||
|
||||
fun aGreyShield() = MessageShield(
|
||||
message = "The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
color = ShieldColor.GREY
|
||||
)
|
||||
|
||||
fun aRedShield() = MessageShield(
|
||||
message = "Encrypted by a device not verified by its owner.",
|
||||
color = ShieldColor.RED
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -16,12 +16,32 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.timeline.item.event
|
||||
|
||||
data class MessageShield(
|
||||
val message: String,
|
||||
val color: ShieldColor,
|
||||
)
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
enum class ShieldColor {
|
||||
RED,
|
||||
GREY
|
||||
@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
|
||||
}
|
||||
|
||||
fun MessageShield.isCritical(): Boolean {
|
||||
return when (this) {
|
||||
is MessageShield.AuthenticityNotGuaranteed -> isCritical
|
||||
is MessageShield.UnknownDevice -> isCritical
|
||||
is MessageShield.UnsignedDevice -> isCritical
|
||||
is MessageShield.UnverifiedIdentity -> isCritical
|
||||
is MessageShield.SentInClear -> isCritical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ShieldColor
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.matrix.rustcomponents.sdk.Reaction
|
||||
import org.matrix.rustcomponents.sdk.ShieldState
|
||||
import 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
|
||||
@@ -135,10 +135,22 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
|
||||
}
|
||||
|
||||
private fun ShieldState?.map(): MessageShield? {
|
||||
return when (this) {
|
||||
is ShieldState.Grey -> MessageShield(message = this.message, color = ShieldColor.GREY)
|
||||
is ShieldState.Red -> MessageShield(message = this.message, color = ShieldColor.RED)
|
||||
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,
|
||||
null -> null
|
||||
is ShieldState.Grey -> false
|
||||
is ShieldState.Red -> true
|
||||
}
|
||||
return when (shieldStateCode) {
|
||||
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
|
||||
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
|
||||
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
|
||||
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
|
||||
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ class KonsistPreviewTest {
|
||||
"TextComposerVoicePreview",
|
||||
"TimelineImageWithCaptionRowPreview",
|
||||
"TimelineItemEventRowForDirectRoomPreview",
|
||||
"TimelineItemEventRowShieldPreview",
|
||||
"TimelineItemEventRowTimestampPreview",
|
||||
"TimelineItemEventRowWithManyReactionsPreview",
|
||||
"TimelineItemEventRowWithRRPreview",
|
||||
|
||||
Reference in New Issue
Block a user