[Message actions] New UI for replies (#545)

* Add 'reply to' UI to the message composer.

* Move the `BlurHashAsyncImage` to `:libraries:designsystem` as it is now used in several modules.

*  Create reusable `AttachmentThumbnail` and associated data classes and enums, it's now added to `:libraries:matrixui`.

* Re-use `AttachmentThumbnail` in a `ActionListView` and `TextComposer`.

* Add 'inReplyTo' models and UI.

* Add min size for images

* Create a separate layout for media items with no reply to info. Also, separate `Timeline__Row` components from `TimelineView`, as it was getting too large.

* Added `EqualWidthColumn` to use inside message bubbles. Also fixed some modifiers for media items replying to other messages.

* Disable `inReplyToClicked`.

* Remove unused resources and libraries.

* Remove any traces of `BlurHashAsyncImage` in `:features:messages`, since it was moved to the design system.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-06-08 12:15:13 +02:00 committed by GitHub
parent 43e4e1627d
commit 25c32cb1e8
56 changed files with 1253 additions and 1008 deletions

View file

@ -33,16 +33,26 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
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.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
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.room.MatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ -55,6 +65,7 @@ class MessagesPresenter @Inject constructor(
private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
) : Presenter<MessagesState> {
@Composable
@ -145,7 +156,38 @@ class MessagesPresenter @Inject constructor(
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "")
val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.mediaSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Video,
blurHash = targetEvent.content.blurHash,
)
is TimelineItemFileContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,
is TimelineItemStateContent,
is TimelineItemEncryptedContent,
is TimelineItemUnknownContent -> null
}
val composerMode = MessageComposerMode.Reply(
senderName = targetEvent.safeSenderName,
eventId = targetEvent.eventId,
attachmentThumbnailInfo = attachmentThumbnailInfo,
defaultContent = textContent,
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -36,19 +35,16 @@ import androidx.compose.material.ListItem
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.VideoCameraBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -56,7 +52,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -67,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -75,8 +71,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.R as StringR
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -189,70 +186,56 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val context = LocalContext.current
val formatter = remember(context) { MessageSummaryFormatterImpl(context) }
val textContent = remember(event.content) { formatter.format(event) }
when (event.content) {
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemProfileChangeContent,
is TimelineItemEncryptedContent,
is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
is TimelineItemImageContent -> {
icon = {
val mediaRequestData = MediaRequestData(
source = event.content.mediaSource,
kind = MediaRequestData.Kind.Thumbnail(32),
)
BlurHashAsyncImage(
model = mediaRequestData,
blurHash = event.content.blurhash,
contentDescription = stringResource(StringR.string.common_image),
contentScale = ContentScale.Crop,
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = event.content.blurhash,
)
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemVideoContent -> {
icon = {
val thumbnailSource = event.content.thumbnailSource
if (thumbnailSource != null) {
val mediaRequestData = MediaRequestData(
source = event.content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(32),
)
BlurHashAsyncImage(
model = mediaRequestData,
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.Video,
blurHash = event.content.blurHash,
contentDescription = stringResource(StringR.string.common_video),
contentScale = ContentScale.Crop,
modifier = imageModifier,
)
} else {
Box(
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.VideoCameraBack,
contentDescription = stringResource(StringR.string.common_video),
)
}
}
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemFileContent -> {
icon = {
Box(
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Attachment,
contentDescription = stringResource(StringR.string.common_file),
modifier = Modifier.rotate(-45f)
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = null,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = null
)
}
)
}
content = { ContentForBody(event.content.body) }
}

View file

@ -28,6 +28,7 @@ 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 io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.random.Random
@ -96,6 +97,7 @@ internal fun aTimelineItemEvent(
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: EventSendState = EventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
): TimelineItem.Event {
return TimelineItem.Event(
id = eventId.value,
@ -113,5 +115,6 @@ internal fun aTimelineItemEvent(
senderDisplayName = "sender",
groupPosition = groupPosition,
sendState = sendState,
inReplyTo = inReplyTo,
)
}

View file

@ -17,30 +17,16 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.MaterialTheme
@ -55,39 +41,24 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
import io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
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.features.messages.impl.timeline.util.defaultTimelineContentPadding
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.LocalColors
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.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
@ -101,12 +72,16 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
}
val lazyListState = rememberLazyListState()
fun inReplyToClicked(eventId: EventId) {
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
}
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@ -124,6 +99,7 @@ fun TimelineView(
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@ -146,6 +122,7 @@ fun TimelineItemRow(
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
@ -179,6 +156,7 @@ fun TimelineItemRow(
onClick = ::onClick,
onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
modifier = modifier,
)
}
@ -209,6 +187,7 @@ fun TimelineItemRow(
highlightedItem = highlightedItem,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
)
}
@ -219,208 +198,6 @@ fun TimelineItemRow(
}
}
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
else -> return
}
}
@Composable
fun TimelineItemEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() {
onUserDataClick(event.senderId)
}
val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
}
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
) {
Row {
if (!event.isMine) {
Spacer(modifier = Modifier.width(4.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.zIndex(1f)
.offset(y = 12.dp)
.clickable(onClick = ::onUserDataClicked)
)
}
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick
)
}
TimelineItemReactionsView(
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
)
}
if (event.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
}
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier.defaultTimelineContentPadding()
)
}
}
}
@Composable
fun MessageEventBubbleContent(
event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
@Composable
fun ContentView(
modifier: Modifier = Modifier
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
modifier = modifier,
)
}
if (showTimestampWithOverlay) {
Box(modifier.wrapContentSize()) {
ContentView()
Box(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 4.dp)
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd)
) {
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
} else {
Column {
ContentView(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
}
}
@Composable
internal fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,

View file

@ -0,0 +1,361 @@
/*
* Copyright (c) 2023 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.theme.LocalColors
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.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@Composable
fun TimelineItemEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() {
onUserDataClick(event.senderId)
}
fun inReplayToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
}
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
) {
Row {
if (!event.isMine) {
Spacer(modifier = Modifier.width(4.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.zIndex(1f)
.offset(y = 12.dp)
.clickable(onClick = ::onUserDataClicked)
)
}
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplayToClicked,
)
}
TimelineItemReactionsView(
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
)
}
if (event.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
}
}
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
modifier: Modifier = Modifier
) {
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
@Composable
fun ContentView(
modifier: Modifier = Modifier
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
modifier = modifier,
)
}
@Composable
fun ContentAndTimestampView(
overlayTimestamp: Boolean,
modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier,
timestampModifier: Modifier = Modifier,
) {
if (overlayTimestamp) {
Box(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
)
}
} else {
Column(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = timestampModifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
/** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */
@Composable
fun SimpleMediaItemLayout(modifier: Modifier = Modifier) {
ContentAndTimestampView(overlayTimestamp = true, modifier = modifier)
}
/** Used for every other type of message, groups the different components in a Column with some space between them. */
@Composable
fun CommonLayout(
inReplyToDetails: InReplyTo.Ready?,
modifier: Modifier = Modifier
) {
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
if (inReplyToDetails != null) {
val senderName = event.senderDisplayName ?: event.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
ReplyToContent(
senderName = senderName,
text = inReplyToDetails.content.body,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.clickable(enabled = true, onClick = inReplyToClick),
)
}
val modifierWithPadding = if (isMediaItem) {
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
} else {
Modifier
}
val contentModifier = if (isMediaItem) {
Modifier.clip(RoundedCornerShape(12.dp))
} else {
Modifier
}
ContentAndTimestampView(
overlayTimestamp = isMediaItem,
contentModifier = contentModifier,
modifier = modifierWithPadding,
)
}
}
if (isMediaItem && replyToDetails == null) {
SimpleMediaItemLayout()
} else {
CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier)
}
}
@Composable
private fun ReplyToContent(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
modifier: Modifier = Modifier,
) {
val paddings = if (attachmentThumbnailInfo != null) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else {
PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp)
}
Row(
modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.width(8.dp))
}
Column(verticalArrangement = Arrangement.SpaceBetween) {
Text(
senderName,
style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = text.orEmpty(),
style = ElementTextStyles.Regular.caption1,
textAlign = TextAlign.Start,
color = LocalColors.current.placeholder,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
when (val type = inReplyTo.content.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
else -> null
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2023 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier.defaultTimelineContentPadding()
)
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 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.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
else -> return
}
}

View file

@ -1,100 +0,0 @@
/*
* Copyright (c) 2023 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.blurhash
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.vanniktech.blurhash.BlurHash
@Composable
fun BlurHashAsyncImage(
model: Any?,
blurHash: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String? = null,
) {
var isLoading by rememberSaveable(model) { mutableStateOf(true) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = model,
contentScale = contentScale,
contentDescription = contentDescription,
onSuccess = { isLoading = false }
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
BlurHashImage(
blurHash = blurHash,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
)
}
}
}
@Composable
fun BlurHashImage(
blurHash: String?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
if (blurHash == null) return
val bitmapState = remember(blurHash) {
mutableStateOf(
// Build a small blurhash image so that it's fast
BlurHash.decode(blurHash, 10, 10)
)
}
DisposableEffect(blurHash) {
onDispose {
bitmapState.value?.recycle()
}
}
bitmapState.value?.let { bitmap ->
Image(
modifier = modifier.fillMaxSize(),
bitmap = bitmap.asImageBitmap(),
contentScale = contentScale,
contentDescription = contentDescription
)
}
}

View file

@ -22,20 +22,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlin.math.max
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
modifier: Modifier = Modifier,
) {
// TODO place this value somewhere else?
val minHeight = max(100, content.height ?: 0)
TimelineItemAspectRatioBox(
height = content.height,
height = minHeight,
aspectRatio = content.aspectRatio,
modifier = modifier
) {

View file

@ -29,9 +29,9 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight

View file

@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor(
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
inReplyTo = currentTimelineItem.event.inReplyTo(),
)
}

View file

@ -23,6 +23,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.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -59,6 +60,7 @@ sealed interface TimelineItem {
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions,
val sendState: EventSendState,
val inReplyTo: InReplyTo?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 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.utils.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
interface MessageSummaryFormatter {
fun format(event: TimelineItem.Event): String
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 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.utils.messagesummary
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
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.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.R
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class MessageSummaryFormatterImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : MessageSummaryFormatter {
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.body
is TimelineItemStateContent -> event.content.body
is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemEncryptedContent -> context.getString(R.string.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(R.string.common_message_removed)
is TimelineItemUnknownContent -> context.getString(R.string.common_unsupported_event)
is TimelineItemImageContent -> context.getString(R.string.common_image)
is TimelineItemVideoContent -> context.getString(R.string.common_video)
is TimelineItemFileContent -> context.getString(R.string.common_file)
}
}
}

View file

@ -29,13 +29,21 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -105,6 +113,105 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action reply to an event with no id does nothing`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
skipItems(1)
// Otherwise we would have some extra items here
ensureAllEventsConsumed()
}
}
@Test
fun `present - handle action reply to an image media message`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemImageContent(
body = "image.jpg",
mediaSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Jpeg,
blurhash = null,
width = 20,
height = 20,
aspectRatio = 1.0f,
)
)
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
}
}
@Test
fun `present - handle action reply to a video media message`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent(
body = "video.mp4",
duration = 10L,
videoSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4,
blurHash = null,
width = 20,
height = 20,
aspectRatio = 1.0f,
)
)
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
}
}
@Test
fun `present - handle action reply to a file media message`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemFileContent(
body = "video.mp4",
fileSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
formattedFileSize = "10 MB",
mimeType = MimeTypes.Pdf,
)
)
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
}
}
@Test
fun `present - handle action edit`() = runTest {
val presenter = createMessagePresenter()
@ -197,6 +304,7 @@ class MessagesPresenterTest {
actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
)
}
}

View file

@ -245,5 +245,6 @@ private fun aMessageEvent(
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID)
sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
)

View file

@ -21,7 +21,9 @@ 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.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
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,11 +31,13 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
) = TimelineItem.Event(
id = AN_EVENT_ID.value,
eventId = AN_EVENT_ID,
id = eventId?.value.orEmpty(),
eventId = eventId,
senderId = A_USER_ID,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
@ -41,5 +45,6 @@ internal fun aMessageEvent(
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID)
sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = inReplyTo,
)

View file

@ -483,5 +483,5 @@ class MessageComposerPresenterTest {
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -42,7 +42,8 @@ class TimelineItemGrouperTest {
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()),
sendState = EventSendState.Sent(AN_EVENT_ID)
sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 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.utils.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
class FakeMessageSummaryFormatter : MessageSummaryFormatter {
private var result = "A message"
override fun format(event: TimelineItem.Event): String = result
fun givenMessageResult(value: String) {
result = value
}
}