diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 037199aba8..95df2428a8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -156,6 +156,9 @@ fun MessagesView( }, onReactionClicked = ::onEmojiReactionClicked, onSendLocationClicked = onSendLocationClicked, + onSwipeToReply = { targetEvent -> + state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) + }, ) }, snackbarHost = { @@ -241,6 +244,7 @@ fun MessagesViewContent( onTimestampClicked: (TimelineItem.Event) -> Unit, onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, + onSwipeToReply: (TimelineItem.Event) -> Unit, ) { Column( modifier = modifier @@ -258,6 +262,7 @@ fun MessagesViewContent( onUserDataClicked = onUserDataClicked, onTimestampClicked = onTimestampClicked, onReactionClicked = onReactionClicked, + onSwipeToReply = onSwipeToReply, ) } if (state.userHasPermissionToSendMessage) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 071dc84116..765becfd11 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -30,7 +30,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.ui.room.canSendEventAsState import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn @@ -44,7 +46,7 @@ private const val backPaginationPageSize = 50 class TimelinePresenter @Inject constructor( private val timelineItemsFactory: TimelineItemsFactory, - room: MatrixRoom, + private val room: MatrixRoom, ) : Presenter { private val timeline = room.timeline @@ -62,6 +64,9 @@ class TimelinePresenter @Inject constructor( val timelineItems by timelineItemsFactory.collectItemsAsState() val paginationState by timeline.paginationState.collectAsState() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) + fun handleEvents(event: TimelineEvents) { when (event) { TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState) @@ -92,6 +97,7 @@ class TimelinePresenter @Inject constructor( return TimelineState( highlightedEventId = highlightedEventId.value, + canReply = userHasPermissionToSendMessage, paginationState = paginationState, timelineItems = timelineItems, eventSink = ::handleEvents diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 0e86614bf3..0aa1bd0160 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList data class TimelineState( val timelineItems: ImmutableList, val highlightedEventId: EventId?, + val canReply: Boolean, val paginationState: MatrixTimeline.PaginationState, val eventSink: (TimelineEvents) -> Unit ) 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 fd7c4402f4..9dd01d60ad 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 @@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList = persistentListOf timelineItems = timelineItems, paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true), highlightedEventId = null, + canReply = true, eventSink = {} ) 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 c632cf1b01..55b54ab224 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 @@ -80,6 +80,7 @@ fun TimelineView( onMessageClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { @@ -120,12 +121,14 @@ fun TimelineView( TimelineItemRow( timelineItem = timelineItem, highlightedItem = state.highlightedEventId?.value, + canReply = state.canReply, onClick = onMessageClicked, onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked, onReactionClick = onReactionClicked, onTimestampClicked = onTimestampClicked, + onSwipeToReply = onSwipeToReply, ) if (index == state.timelineItems.lastIndex) { onReachedLoadMore() @@ -145,12 +148,14 @@ fun TimelineView( fun TimelineItemRow( timelineItem: TimelineItem, highlightedItem: String?, + canReply: Boolean, onUserDataClick: (UserId) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { when (timelineItem) { @@ -173,12 +178,14 @@ fun TimelineItemRow( TimelineItemEventRow( event = timelineItem, isHighlighted = highlightedItem == timelineItem.identifier(), + canReply = canReply, onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onUserDataClick = onUserDataClick, inReplyToClick = inReplyToClick, onReactionClick = onReactionClick, onTimestampClicked = onTimestampClicked, + onSwipeToReply = { onSwipeToReply(timelineItem) }, modifier = modifier, ) } @@ -207,12 +214,14 @@ fun TimelineItemRow( TimelineItemRow( timelineItem = subGroupEvent, highlightedItem = highlightedItem, + canReply = false, onClick = onClick, onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, onTimestampClicked = onTimestampClicked, onReactionClick = onReactionClick, + onSwipeToReply = {}, ) } } @@ -314,5 +323,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) { onUserDataClicked = {}, onMessageLongClicked = {}, onReactionClicked = { _, _ -> }, + onSwipeToReply = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt new file mode 100644 index 0000000000..de40ae8dc1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt @@ -0,0 +1,77 @@ +/* + * 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.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon + +/** + * A swipe indicator that appears when swiping to reply to a message. + * + * @param swipeProgress the progress of the swipe, between 0 and X. When swipeProgress >= 1 the swipe will be detected. + * @param modifier the modifier to apply to this Composable root. + */ +@Composable +fun RowScope.ReplySwipeIndicator( + swipeProgress: () -> Float, + modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier + .align(Alignment.CenterVertically) + .graphicsLayer { + translationX = 36.dp.toPx() * swipeProgress().coerceAtMost(1f) + alpha = swipeProgress() + }, + contentDescription = null, + resourceId = VectorIcons.Reply, + ) +} + +@Preview +@Composable +internal fun ReplySwipeIndicatorLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun ReplySwipeIndicatorDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(modifier = Modifier.fillMaxWidth()) { + for (i in 0..8) { + Row { ReplySwipeIndicator(swipeProgress = { i / 8f }) } + } + Row { ReplySwipeIndicator(swipeProgress = { 1.5f }) } + Row { ReplySwipeIndicator(swipeProgress = { 2f }) } + Row { ReplySwipeIndicator(swipeProgress = { 3f }) } + } +} 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 index f2f7328821..b46fb93e9c 100644 --- 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package io.element.android.features.messages.impl.timeline.components import androidx.compose.foundation.Canvas @@ -33,7 +35,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DismissDirection +import androidx.compose.material3.DismissState +import androidx.compose.material3.DismissValue +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismiss +import androidx.compose.material3.rememberDismissState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -86,12 +94,14 @@ import org.jsoup.Jsoup fun TimelineItemEventRow( event: TimelineItem.Event, isHighlighted: Boolean, + canReply: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, + onSwipeToReply: () -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } @@ -108,6 +118,70 @@ fun TimelineItemEventRow( inReplyToClick(inReplyToEventId) } + if (canReply) { + val dismissState = rememberDismissState( + confirmValueChange = { + if (it == DismissValue.DismissedToEnd) { + onSwipeToReply() + } + // Do not dismiss the message, return false! + false + } + ) + SwipeToDismiss( + state = dismissState, + background = { + ReplySwipeIndicator({ dismissState.toSwipeProgress() }) + }, + directions = setOf(DismissDirection.StartToEnd), + dismissContent = { + TimelineItemEventRowContent( + event = event, + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + onTimestampClicked = onTimestampClicked, + inReplyToClicked = ::inReplyToClicked, + onUserDataClicked = ::onUserDataClicked, + onReactionClicked = ::onReactionClicked, + ) + } + ) + } else { + TimelineItemEventRowContent( + event = event, + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + onTimestampClicked = onTimestampClicked, + inReplyToClicked = ::inReplyToClicked, + onUserDataClicked = ::onUserDataClicked, + onReactionClicked = ::onReactionClicked, + ) + } + // This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage. + if (event.groupPosition.isNew()) { + Spacer(modifier = modifier.height(16.dp)) + } else { + Spacer(modifier = modifier.height(2.dp)) + } +} + +@Composable +private fun TimelineItemEventRowContent( + event: TimelineItem.Event, + isHighlighted: Boolean, + interactionSource: MutableInteractionSource, + onClick: () -> Unit, + onLongClick: () -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + inReplyToClicked: () -> Unit, + onUserDataClicked: () -> Unit, + onReactionClicked: (emoji: String) -> Unit, + modifier: Modifier = Modifier, +) { // To avoid using negative offset, we display in this Box a column with: // - Spacer to give room to the Sender information if they must be displayed; // - The message bubble; @@ -140,7 +214,7 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onMessageClick = onClick, onMessageLongClick = onLongClick, - inReplyToClick = ::inReplyToClicked, + inReplyToClick = inReplyToClicked, onTimestampClicked = { onTimestampClicked(event) } @@ -158,25 +232,27 @@ fun TimelineItemEventRow( Modifier .padding(horizontal = 16.dp) .align(Alignment.TopStart) - .clickable(onClick = ::onUserDataClicked) + .clickable(onClick = onUserDataClicked) ) } // Align to the bottom of the box if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactionsView( reactionsState = event.reactionsState, - onReactionClicked = ::onReactionClicked, + onReactionClicked = onReactionClicked, modifier = Modifier .align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) ) } } - // This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage. - if (event.groupPosition.isNew()) { - Spacer(modifier = modifier.height(16.dp)) - } else { - Spacer(modifier = modifier.height(2.dp)) +} + +private fun DismissState.toSwipeProgress(): Float { + return when (targetValue) { + DismissValue.Default -> 0f + DismissValue.DismissedToEnd -> progress * 3 + DismissValue.DismissedToStart -> progress * 3 } } @@ -452,12 +528,14 @@ private fun ContentToPreview() { ) ), isHighlighted = false, + canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onTimestampClicked = {}, + onSwipeToReply = {}, ) TimelineItemEventRow( event = aTimelineItemEvent( @@ -467,12 +545,14 @@ private fun ContentToPreview() { ) ), isHighlighted = false, + canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onTimestampClicked = {}, + onSwipeToReply = {}, ) } } @@ -492,7 +572,7 @@ internal fun TimelineItemEventRowWithReplyDarkPreview() = private fun ContentToPreviewWithReply() { Column { sequenceOf(false, true).forEach { - val replyContent = if(it) { + val replyContent = if (it) { // Short "Message which are being replied." } else { @@ -509,12 +589,14 @@ private fun ContentToPreviewWithReply() { inReplyTo = aInReplyToReady(replyContent) ), isHighlighted = false, + canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onTimestampClicked = {}, + onSwipeToReply = {}, ) TimelineItemEventRow( event = aTimelineItemEvent( @@ -525,12 +607,14 @@ private fun ContentToPreviewWithReply() { inReplyTo = aInReplyToReady(replyContent) ), isHighlighted = false, + canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onTimestampClicked = {}, + onSwipeToReply = {}, ) } } @@ -578,12 +662,14 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) { senderDisplayName = if (useDocument) "Document case" else "Text case", ), isHighlighted = false, + canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onTimestampClicked = {}, + onSwipeToReply = {}, ) } } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_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.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f15ddfd706 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6418097195ef3c6a166d216913469aa3adf30a4fb42fbda21bc27e321d431410 +size 9792 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_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.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8c614712a5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f64649306919ac8dfeb6a7729f73a0617e8fc809c2d62f3aeabac6566bced1e +size 9151