Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra
2023-05-26 13:43:51 +02:00
37 changed files with 163 additions and 70 deletions

1
changelog.d/464.feature Normal file
View File

@@ -0,0 +1 @@
Display timestamps for text messages.

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
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.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.random.Random
@@ -49,7 +50,8 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
aTimelineItemEvent(
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.Middle
groupPosition = TimelineItemGroupPosition.Middle,
sendState = EventSendState.SendingFailed("Message failed to send"),
),
aTimelineItemEvent(
isMine = false,
@@ -71,7 +73,8 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
aTimelineItemEvent(
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.Middle
groupPosition = TimelineItemGroupPosition.Middle,
sendState = EventSendState.SendingFailed("Message failed to send"),
),
aTimelineItemEvent(
isMine = true,
@@ -88,14 +91,15 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
}
internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First,
sendState: EventSendState = EventSendState.Sent(eventId),
): TimelineItem.Event {
val randomId = "\$" + Random.nextInt().toString()
return TimelineItem.Event(
id = randomId,
eventId = EventId(randomId),
id = eventId.value,
eventId = eventId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"),
content = content,
@@ -104,8 +108,10 @@ internal fun aTimelineItemEvent(
AggregatedReaction("👍", "1")
)
),
sentTime = "12:34",
isMine = isMine,
senderDisplayName = "sender",
groupPosition = groupPosition,
sendState = sendState,
)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -38,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +53,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -67,18 +71,24 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun TimelineView(
@@ -261,12 +271,25 @@ fun TimelineItemEventRow(
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
)
Column {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
)
TimestampView(
formattedTime = event.sentTime,
hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed,
isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse(),
onClick = {
// TODO trigger either resending the message or opening the message edition history. This will be implemented later
},
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.align(Alignment.End),
)
}
}
TimelineItemReactionsView(
reactionsState = event.reactionsState,
@@ -321,6 +344,36 @@ fun TimelineItemStateEventRow(
}
}
@Composable
private fun TimestampView(
formattedTime: String,
isMessageEdited: Boolean,
hasMessageSendingFailed: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(modifier = modifier.clickable(onClick = onClick)) {
if (isMessageEdited) {
Text(
stringResource(StringR.string.common_edited_suffix),
style = ElementTextStyles.Regular.caption2,
color = tint ?: MaterialTheme.colorScheme.secondary,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
formattedTime,
style = ElementTextStyles.Regular.caption1,
color = tint ?: MaterialTheme.colorScheme.secondary,
)
if (hasMessageSendingFailed && tint != null) {
Spacer(modifier = Modifier.width(2.dp))
Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp))
}
}
}
@Composable
private fun MessageSenderInformation(
sender: String,

View File

@@ -40,7 +40,8 @@ class TimelineItemContentMessageFactory @Inject constructor() {
return when (val messageType = content.type) {
is EmoteMessageType -> TimelineItemEmoteContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument()
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
is ImageMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
@@ -77,11 +78,13 @@ class TimelineItemContentMessageFactory @Inject constructor() {
)
is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument()
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
is TextMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument()
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
else -> TimelineItemUnknownContent
}

View File

@@ -23,8 +23,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
@@ -55,6 +58,9 @@ class TimelineItemEventFactory @Inject constructor(
}
}
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderDisplayName ?: currentSender.value,
@@ -69,8 +75,10 @@ class TimelineItemEventFactory @Inject constructor(
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState()
reactionsState = currentTimelineItem.computeReactionsState(),
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
)
}

View File

@@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData
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.EventSendState
import kotlinx.collections.immutable.ImmutableList
@Immutable
@@ -56,7 +57,8 @@ sealed interface TimelineItem {
val sentTime: String = "",
val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions
val reactionsState: TimelineItemReactions,
val sendState: EventSendState,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine

View File

@@ -20,7 +20,8 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
override val htmlDocument: Document?
override val htmlDocument: Document?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
}

View File

@@ -32,6 +32,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemRedactedContent(),
aTimelineItemTextContent(),
aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true),
)
}
@@ -48,7 +49,8 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineIt
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
body = "Emote",
htmlDocument = null
htmlDocument = null,
isEdited = false,
)
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
@@ -57,14 +59,16 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
body = "Notice",
htmlDocument = null
htmlDocument = null,
isEdited = false,
)
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
fun aTimelineItemTextContent() = TimelineItemTextContent(
body = "Text",
htmlDocument = null
htmlDocument = null,
isEdited = false,
)
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent

View File

@@ -20,7 +20,8 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent(
override val body: String,
override val htmlDocument: Document?
override val htmlDocument: Document?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"
}

View File

@@ -21,4 +21,5 @@ import org.jsoup.nodes.Document
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val isEdited: Boolean
}

View File

@@ -20,7 +20,8 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent(
override val body: String,
override val htmlDocument: Document?
override val htmlDocument: Document?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent{
override val type: String = "TimelineItemTextContent"
}

View File

@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
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
@@ -107,7 +108,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
@@ -137,7 +138,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
@@ -173,5 +174,6 @@ private fun aMessageEvent(
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID)
)

View File

@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
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
@@ -29,7 +30,7 @@ import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null),
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
) = TimelineItem.Event(
id = AN_EVENT_ID.value,
eventId = AN_EVENT_ID,
@@ -39,5 +40,6 @@ internal fun aMessageEvent(
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID)
)

View File

@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -40,7 +41,8 @@ class TimelineItemGrouperTest {
senderAvatar = anAvatarData(),
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList())
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()),
sendState = EventSendState.Sent(AN_EVENT_ID)
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))

View File

@@ -32,7 +32,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
EventTimelineItem(
uniqueIdentifier = it.uniqueIdentifier(),
eventId = it.eventId()?.let { EventId(it) },
eventId = it.eventId()?.let(::EventId),
isEditable = it.isEditable(),
isLocal = it.isLocal(),
isOwn = it.isOwn(),