Display timestamps for text messages (#465)
This commit is contained in:
parent
efcd969b4c
commit
7aae71c2d4
37 changed files with 158 additions and 66 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -39,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
|
||||
|
|
@ -51,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
|
||||
|
|
@ -68,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,8 +270,21 @@ fun TimelineItemEventRow(
|
|||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
||||
Column {
|
||||
val contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 6.dp)
|
||||
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
||||
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,
|
||||
|
|
@ -319,6 +341,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,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,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 height = messageType.info?.height?.toFloat()
|
||||
|
|
@ -59,11 +60,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
aTimelineItemRedactedContent(),
|
||||
aTimelineItemTextContent(),
|
||||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +46,8 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineIt
|
|||
|
||||
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
|
||||
body = "Emote",
|
||||
htmlDocument = null
|
||||
htmlDocument = null,
|
||||
isEdited = false,
|
||||
)
|
||||
|
||||
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
|
||||
|
|
@ -54,14 +56,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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ import org.jsoup.nodes.Document
|
|||
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||
val body: String
|
||||
val htmlDocument: Document?
|
||||
val isEdited: Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue