Browse Source

Merge pull request #774 from vector-im/feature/bma/swipeToReply

Swipe to reply
pull/788/head
Benoit Marty 1 year ago committed by GitHub
parent
commit
f0f00e40a0
  1. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  2. 8
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
  3. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
  4. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
  5. 10
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
  6. 77
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt
  7. 104
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
  8. BIN
      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
  9. BIN
      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

5
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

@ -156,6 +156,9 @@ fun MessagesView( @@ -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( @@ -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( @@ -258,6 +262,7 @@ fun MessagesViewContent(
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
onSwipeToReply = onSwipeToReply,
)
}
if (state.userHasPermissionToSendMessage) {

8
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 @@ -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 @@ -44,7 +46,7 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
room: MatrixRoom,
private val room: MatrixRoom,
) : Presenter<TimelineState> {
private val timeline = room.timeline
@ -62,6 +64,9 @@ class TimelinePresenter @Inject constructor( @@ -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( @@ -92,6 +97,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
canReply = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
eventSink = ::handleEvents

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt

@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList @@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val highlightedEventId: EventId?,
val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val eventSink: (TimelineEvents) -> Unit
)

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt

@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf @@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
highlightedEventId = null,
canReply = true,
eventSink = {}
)

10
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt

@ -80,6 +80,7 @@ fun TimelineView( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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) { @@ -314,5 +323,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onUserDataClicked = {},
onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
onSwipeToReply = {},
)
}

77
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt

@ -0,0 +1,77 @@ @@ -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 }) }
}
}

104
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt

@ -14,6 +14,8 @@ @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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() { @@ -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() { @@ -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() = @@ -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() { @@ -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() { @@ -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) { @@ -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 = {},
)
}
}

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save