Merge pull request #774 from vector-im/feature/bma/swipeToReply
Swipe to reply
This commit is contained in:
@@ -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) {
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user