diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index cbaeb26c93..015e76f448 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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 { @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) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 86d957e777..d7222be960 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -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) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 2cee290871..7e23657974 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index f5ebdcc796..f8717517f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt new file mode 100644 index 0000000000..25f5ff2399 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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 + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt new file mode 100644 index 0000000000..7b0a16b9a8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -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() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt new file mode 100644 index 0000000000..13a6610ffe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index f2cb2b2b92..566e899a36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -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 ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 883f7b30f3..aa024e1033 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 87792edf1d..ecd85a213a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor( groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState(), sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet, + inReplyTo = currentTimelineItem.event.inReplyTo(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index c004901232..5a5f382a54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt new file mode 100644 index 0000000000..241b1282a0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt new file mode 100644 index 0000000000..b924a9e7b4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt @@ -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) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 28765cc110..cb1c923397 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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(), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index 169912d534..b1aa411148 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -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, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt index bd6be5b517..496c022983 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -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, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index bfef3b4011..8fc9b702bc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -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) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 3edde16841..b1a17beb6c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -42,7 +42,8 @@ class TimelineItemGrouperTest { senderDisplayName = "", content = TimelineItemStateEventContent(body = "a state event"), reactionsState = TimelineItemReactions(emptyList().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")) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt new file mode 100644 index 0000000000..3f8205e555 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt @@ -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 + } +} diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 09361a5667..f0d937ead3 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -31,6 +31,7 @@ android { // Should not be there, but this is a POC implementation(libs.coil.compose) implementation(libs.accompanist.systemui) + implementation(libs.vanniktech.blurhash) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt similarity index 97% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt index 9237e87e9d..0ddd0b7346 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.timeline.components.blurhash +package io.element.android.libraries.designsystem.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt new file mode 100644 index 0000000000..8804066da8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -0,0 +1,58 @@ +/* + * 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.libraries.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * Used to create a column where all children have the same width. + * It will first measure all children, get the largest width and re-measure all children with this width as the minWidth. + * + * *Note*: If all children already have the same width, it skips the 2nd measuring and acts like a normal Column. + */ +@Composable +fun EqualWidthColumn( + modifier: Modifier = Modifier, + spacing: Dp = 0.dp, + content: @Composable () -> Unit +) { + SubcomposeLayout(modifier = modifier) { constraints -> + val measurables = subcompose(0, content).map { it.measure(constraints) } + val maxWidth = measurables.maxOf { it.width } + val newConstraints = constraints.copy(minWidth = maxWidth) + val newMeasurables = if (measurables.all { it.width == maxWidth }) { + // Skip re-measuring if all children have the same width + measurables + } else { + // Re-measure with the largest width as the minWidth to have all children constrained to the same width + subcompose(1, content).map { it.measure(newConstraints) } + } + val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() + layout(maxWidth, totalHeight) { + var yPosition = 0 + newMeasurables.forEach { measurable -> + measurable.placeRelative(0, yPosition) + yPosition += measurable.height + spacing.roundToPx() + } + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index dafaa95936..57e3993926 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -28,11 +28,25 @@ sealed interface EventContent data class MessageContent( val body: String, - val inReplyTo: EventId?, + val inReplyTo: InReplyTo?, val isEdited: Boolean, val type: MessageType? ) : EventContent + +sealed interface InReplyTo { + data class NotLoaded(val eventId: EventId) : InReplyTo + data class Ready( + val eventId: EventId, + val content: MessageContent, + val senderId: UserId, + val senderDisplayName: String?, + val senderAvatarUrl: String?, + ) : InReplyTo + + object Error : InReplyTo +} + object RedactedContent : EventContent data class StickerContent( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8b667107d7..81aa2dc5c4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -32,4 +32,12 @@ data class EventTimelineItem( val senderProfile: ProfileTimelineDetails, val timestamp: Long, val content: EventContent -) +) { + fun inReplyTo(): InReplyTo? { + return (content as? MessageContent)?.inReplyTo + } + fun hasNotLoadedInReplyTo(): Boolean { + val details = inReplyTo() + return details is InReplyTo.NotLoaded || details is InReplyTo.Error + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt index 5b4eaa9e36..c90e672f28 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -16,12 +16,19 @@ package io.element.android.libraries.matrix.impl.timeline +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber class MatrixTimelineItemMapper( + private val room: Room, + private val coroutineScope: CoroutineScope, private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(), ) { @@ -30,6 +37,12 @@ class MatrixTimelineItemMapper( val asEvent = it.asEvent() if (asEvent != null) { val eventTimelineItem = eventTimelineItemMapper.map(asEvent) + + + if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) { + fetchDetailsForEvent(eventTimelineItem.eventId!!) + } + return MatrixTimelineItem.Event(eventTimelineItem) } val asVirtual = it.asVirtual() @@ -39,4 +52,13 @@ class MatrixTimelineItemMapper( } return MatrixTimelineItem.Other } + + private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch { + runCatching { + room.fetchDetailsForEvent(eventId.value) + }.onFailure { + Timber.e(it) + } + } + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 923815a714..408861226b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -63,6 +63,8 @@ class RustMatrixTimeline( ) private val timelineItemFactory = MatrixTimelineItemMapper( + room = innerRoom, + coroutineScope = coroutineScope, virtualTimelineItemMapper = VirtualTimelineItemMapper(), eventTimelineItemMapper = EventTimelineItemMapper( contentMapper = TimelineEventContentMapper( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 2e4693c1fb..8a052d1a3a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -17,11 +17,13 @@ package io.element.android.libraries.matrix.impl.timeline.item.event 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.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody 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.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -31,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.impl.media.map import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.RepliedToEventDetails import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat @@ -66,9 +70,26 @@ class EventMessageMapper { } } } + val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) + val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> + when (details) { + is RepliedToEventDetails.Ready -> { + val senderProfile = details.senderProfile as? ProfileDetails.Ready + InReplyTo.Ready( + eventId = inReplyToId!!, + content = map(details.message), + senderId = UserId(details.sender), + senderDisplayName = senderProfile?.displayName, + senderAvatarUrl = senderProfile?.avatarUrl, + ) + } + is RepliedToEventDetails.Error -> InReplyTo.Error + is RepliedToEventDetails.Pending, is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!) + } + } MessageContent( body = it.body(), - inReplyTo = it.inReplyTo()?.eventId?.let(::EventId), + inReplyTo = inReplyToEvent, isEdited = it.isEdited(), type = type ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt new file mode 100644 index 0000000000..5a3bad8988 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -0,0 +1,93 @@ +/* + * 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.libraries.matrix.ui.components + +import android.os.Parcelable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.VideoCameraBack +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlinx.parcelize.Parcelize + +@Composable +fun AttachmentThumbnail( + info: AttachmentThumbnailInfo, + modifier: Modifier = Modifier, + thumbnailSize: Long = 32L, + backgroundColor: Color = MaterialTheme.colorScheme.surface, +) { + if (info.mediaSource != null) { + val mediaRequestData = MediaRequestData( + source = info.mediaSource, + kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), + ) + BlurHashAsyncImage( + model = mediaRequestData, + blurHash = info.blurHash, + contentDescription = info.textContent, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else { + Box( + modifier = modifier.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + when (info.type) { + AttachmentThumbnailType.Video -> { + Icon( + imageVector = Icons.Outlined.VideoCameraBack, + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.File -> { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = info.textContent, + modifier = Modifier.rotate(-45f) + ) + } + else -> Unit + } + } + } +} + +@Parcelize +enum class AttachmentThumbnailType: Parcelable { + Image, Video, File +} + +@Parcelize +data class AttachmentThumbnailInfo( + val mediaSource: MediaSource?, + val textContent: String?, + val type: AttachmentThumbnailType?, + val blurHash: String?, +): Parcelable diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts index 6af1d8c598..dee2abc5c6 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/build.gradle.kts @@ -22,9 +22,6 @@ plugins { android { namespace = "io.element.android.libraries.textcomposer" - buildFeatures { - viewBinding = true - } } dependencies { @@ -33,9 +30,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(libs.wysiwyg) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.material) ksp(libs.showkase.processor) } diff --git a/libraries/textcomposer/src/main/AndroidManifest.xml b/libraries/textcomposer/src/main/AndroidManifest.xml deleted file mode 100644 index 19db0c3d57..0000000000 --- a/libraries/textcomposer/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt index 093fb865fa..5539b781ea 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer import android.os.Parcelable import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import kotlinx.parcelize.Parcelize sealed interface MessageComposerMode : Parcelable { @@ -38,6 +39,7 @@ sealed interface MessageComposerMode : Parcelable { @Parcelize class Reply( val senderName: String, + val attachmentThumbnailInfo: AttachmentThumbnailInfo?, override val eventId: EventId, override val defaultContent: CharSequence ) : Special(eventId, defaultContent) diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 92bcff5000..ccf4030ce1 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -59,8 +59,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.ElementTextStyles @@ -73,6 +75,10 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface 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.media.MediaSource +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 import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @@ -180,21 +186,99 @@ private fun ComposerModeView( ) { when (composerMode) { is MessageComposerMode.Edit -> { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) { - Icon( - resourceId = VectorIcons.Edit, - contentDescription = stringResource(R.string.editing), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(16.dp), - ) + EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent.toString(), + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) { + Icon( + resourceId = VectorIcons.Edit, + contentDescription = stringResource(R.string.editing), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + Text( + stringResource(R.string.editing), + style = ElementTextStyles.Regular.caption2, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(StringR.string.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + 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(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(verticalArrangement = Arrangement.SpaceEvenly) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { Text( - stringResource(R.string.editing), - style = ElementTextStyles.Regular.caption2, + senderName, + style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium), textAlign = TextAlign.Start, - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) Icon( @@ -209,11 +293,19 @@ private fun ComposerModeView( interactionSource = MutableInteractionSource(), indication = rememberRipple(bounded = false) ), - ) } + + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTextStyles.Regular.caption1, + textAlign = TextAlign.Start, + color = LocalColors.current.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - else -> Unit } } @@ -289,14 +381,30 @@ private fun BoxScope.SendButton( @Preview @Composable -internal fun TextComposerLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun TextComposerSimpleLightPreview() = ElementPreviewLight { SimpleContentToPreview() } @Preview @Composable -internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun TextComposerSimpleDarkPreview() = ElementPreviewDark { SimpleContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditLightPreview() = ElementPreviewLight { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditDarkPreview() = ElementPreviewDark { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyLightPreview() = ElementPreviewLight { ReplyContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyDarkPreview() = ElementPreviewDark { ReplyContentToPreview() } @Composable -private fun ContentToPreview() { +private fun SimpleContentToPreview() { Column { TextComposer( onSendMessage = {}, @@ -322,10 +430,89 @@ private fun ContentToPreview() { composerCanSendMessage = true, composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", ) + } +} + +@Composable +private fun EditContentToPreview() { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) +} + +@Composable +private fun ReplyContentToPreview() { + Column { TextComposer( onSendMessage = {}, onComposerTextChange = {}, - composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = null, + defaultContent = "A message\n" + + "With several lines\n" + + "To preview larger textfields and long lines with overflow" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/image.jpg"), + textContent = "image.jpg", + type = AttachmentThumbnailType.Image, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "image.jpg" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/video.mp4"), + textContent = "video.mp4", + type = AttachmentThumbnailType.Video, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "video.mp4" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = null, + textContent = "logs.txt", + type = AttachmentThumbnailType.File, + blurHash = null, + ), + defaultContent = "logs.txt" + ), onResetComposerMode = {}, composerCanSendMessage = true, composerText = "A message", diff --git a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml deleted file mode 100644 index 647dc58213..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml b/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml deleted file mode 100644 index 3d66d9db2e..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml deleted file mode 100644 index 4a3051618d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml deleted file mode 100644 index 625399e53a..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml deleted file mode 100644 index d8d5f8ef4d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml deleted file mode 100644 index 11a63eb7bb..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml deleted file mode 100644 index b67fad6749..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml deleted file mode 100644 index 009cfc303f..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml deleted file mode 100644 index 7591806ba6..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml deleted file mode 100644 index 937c3a08a4..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml deleted file mode 100644 index ac8ff7a96d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml b/libraries/textcomposer/src/main/res/drawable/ic_quote.xml deleted file mode 100644 index 706bf88faa..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml deleted file mode 100644 index 6f812c8b44..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml deleted file mode 100644 index 3373db1399..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml b/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml deleted file mode 100644 index 2e8eb80855..0000000000 --- a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml deleted file mode 100644 index fa264045e7..0000000000 --- a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 72f00d3be0..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7441f73e8567bbac360867f9b860621ec4766a67d5295d04cda45a09f942d0b5 -size 47865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e85b7a81af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1207e4830152c3387f9464fa4f10bd92f605d14b37f1c556d8e610060246ac2e +size 14094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e266f9795 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:127ec47cd443c96270f07d6d3ca132a184c6ad99ab8ade04dc3d60f5a6f555da +size 13575 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 4cb8b441f3..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f44fe7919578afe43e77b414f294e2ae7f1761c9528feeab548303428bfeba43 -size 46117 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8904142b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8922908539dc4978a23f72342e46a4c259322559d1f0dbe978dcf3ae8a4e79bf +size 66820 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4c0408f4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a5b67e808ef8d171167e7fb241ba76b7e6d5ae48d193f6384bf3e2c4a0743f3 +size 66423 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c8d0ea0a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97fb04ff64617a6ba8c683a39921864bded4b05269c958aa4c91fda0bc21963a +size 39298 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6896fab3c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd9f6eaf4bfc4b226f1fde5bdaf0262b95e3f9ac2e7e0d90648d72ef0add11d2 +size 37492