Handle swipe to reply gesture.
This commit is contained in:
parent
7492521303
commit
4a098583a0
7 changed files with 197 additions and 10 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<TimelineState> {
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
|||
timelineItems = timelineItems,
|
||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||
highlightedEventId = null,
|
||||
canReply = true,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -165,6 +170,10 @@ fun TimelineItemRow(
|
|||
onClick(timelineItem)
|
||||
}
|
||||
|
||||
fun onSwipeToReply() {
|
||||
onSwipeToReply(timelineItem)
|
||||
}
|
||||
|
||||
fun onLongClick() {
|
||||
onLongClick(timelineItem)
|
||||
}
|
||||
|
|
@ -181,12 +190,14 @@ fun TimelineItemRow(
|
|||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
canReply = canReply,
|
||||
onClick = ::onClick,
|
||||
onLongClick = ::onLongClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = ::onSwipeToReply,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -215,12 +226,14 @@ fun TimelineItemRow(
|
|||
TimelineItemRow(
|
||||
timelineItem = subGroupEvent,
|
||||
highlightedItem = highlightedItem,
|
||||
canReply = false,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -322,5 +335,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
|
|||
onUserDataClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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.
|
||||
*/
|
||||
@Composable
|
||||
fun RowScope.ReplySwipeIndicator(swipeProgress: Float) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(start = 36.dp * 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,72 @@ 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(
|
||||
modifier = Modifier,
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = ::onReactionClicked
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
modifier = Modifier,
|
||||
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(
|
||||
modifier: Modifier,
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClicked: () -> Unit,
|
||||
onUserDataClicked: () -> Unit,
|
||||
onReactionClicked: (emoji: String) -> Unit
|
||||
) {
|
||||
// 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 +216,7 @@ fun TimelineItemEventRow(
|
|||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
inReplyToClick = inReplyToClicked,
|
||||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
}
|
||||
|
|
@ -158,25 +234,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 +530,14 @@ private fun ContentToPreview() {
|
|||
)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
|
|
@ -467,12 +547,14 @@ private fun ContentToPreview() {
|
|||
)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -492,7 +574,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 +591,14 @@ private fun ContentToPreviewWithReply() {
|
|||
inReplyTo = aInReplyToReady(replyContent)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
|
|
@ -525,12 +609,14 @@ private fun ContentToPreviewWithReply() {
|
|||
inReplyTo = aInReplyToReady(replyContent)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -578,12 +664,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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue