Skip to content

Commit 23f81a8

Browse files
authored
feat: swipe to react (#WPB-16545) (#4008)
1 parent fcb37fb commit 23f81a8

File tree

9 files changed

+216
-57
lines changed

9 files changed

+216
-57
lines changed

app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt

+31-5
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import com.wire.android.ui.destinations.MediaGalleryScreenDestination
129129
import com.wire.android.ui.destinations.MessageDetailsScreenDestination
130130
import com.wire.android.ui.destinations.OtherUserProfileScreenDestination
131131
import com.wire.android.ui.destinations.SelfUserProfileScreenDestination
132+
import com.wire.android.ui.emoji.EmojiPickerBottomSheet
132133
import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldHaveSmallBottomPadding
133134
import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldShowHeader
134135
import com.wire.android.ui.home.conversations.ConversationSnackbarMessages.OnFileDownloaded
@@ -150,7 +151,7 @@ import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewS
150151
import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel
151152
import com.wire.android.ui.home.conversations.messages.item.MessageClickActions
152153
import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem
153-
import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration
154+
import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration
154155
import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel
155156
import com.wire.android.ui.home.conversations.model.ExpirationStatus
156157
import com.wire.android.ui.home.conversations.model.UIMessage
@@ -1088,6 +1089,8 @@ private fun ConversationScreenContent(
10881089
LazyListState(unreadEventCount)
10891090
}
10901091

1092+
var showEmojiPickerForMessage by remember { mutableStateOf<String?>(null) }
1093+
10911094
MessageComposer(
10921095
conversationId = conversationId,
10931096
bottomSheetVisible = bottomSheetVisible,
@@ -1115,6 +1118,9 @@ private fun ConversationScreenContent(
11151118
),
11161119
onSelfDeletingMessageRead = onSelfDeletingMessageRead,
11171120
onSwipedToReply = onSwipedToReply,
1121+
onSwipedToReact = { message ->
1122+
showEmojiPickerForMessage = message.header.messageId
1123+
},
11181124
conversationDetailsData = conversationDetailsData,
11191125
selectedMessageId = selectedMessageId,
11201126
interactionAvailability = messageComposerStateHolder.messageComposerViewState.value.interactionAvailability,
@@ -1136,6 +1142,19 @@ private fun ConversationScreenContent(
11361142
onAttachmentPicked = onAttachmentPicked,
11371143
onAudioRecorded = onAudioRecorded,
11381144
)
1145+
1146+
showEmojiPickerForMessage?.let { messageId ->
1147+
EmojiPickerBottomSheet(
1148+
isVisible = true,
1149+
onEmojiSelected = { emoji ->
1150+
onReactionClicked(messageId, emoji)
1151+
showEmojiPickerForMessage = null
1152+
},
1153+
onDismiss = {
1154+
showEmojiPickerForMessage = null
1155+
},
1156+
)
1157+
}
11391158
}
11401159

11411160
@Composable
@@ -1182,6 +1201,7 @@ fun MessageList(
11821201
assetStatuses: PersistentMap<String, MessageAssetStatus>,
11831202
onUpdateConversationReadDate: (String) -> Unit,
11841203
onSwipedToReply: (UIMessage.Regular) -> Unit,
1204+
onSwipedToReact: (UIMessage.Regular) -> Unit,
11851205
onSelfDeletingMessageRead: (UIMessage) -> Unit,
11861206
conversationDetailsData: ConversationDetailsData,
11871207
selectedMessageId: String?,
@@ -1281,9 +1301,15 @@ fun MessageList(
12811301
}
12821302
}
12831303
}
1284-
val swipableConfiguration = remember(message) {
1285-
SwipableMessageConfiguration.SwipableToReply {
1286-
onSwipedToReply(it)
1304+
1305+
val swipeableConfiguration = remember(message) {
1306+
if (message is UIMessage.Regular && message.isSwipeable) {
1307+
SwipeableMessageConfiguration.Swipeable(
1308+
onSwipedRight = { onSwipedToReply(message) }.takeIf { message.isReplyable },
1309+
onSwipedLeft = { onSwipedToReact(message) }.takeIf { message.isReactionAllowed },
1310+
)
1311+
} else {
1312+
SwipeableMessageConfiguration.NotSwipeable
12871313
}
12881314
}
12891315

@@ -1294,7 +1320,7 @@ fun MessageList(
12941320
useSmallBottomPadding = useSmallBottomPadding,
12951321
assetStatus = assetStatuses[message.header.messageId]?.transferStatus,
12961322
clickActions = clickActions,
1297-
swipableMessageConfiguration = swipableConfiguration,
1323+
swipeableMessageConfiguration = swipeableConfiguration,
12981324
onSelfDeletingMessageRead = onSelfDeletingMessageRead,
12991325
isSelectedMessage = (message.header.messageId == selectedMessageId),
13001326
failureInteractionAvailable = interactionAvailability == InteractionAvailability.ENABLED

app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator
4040
import com.wire.android.ui.home.conversations.info.ConversationDetailsData
4141
import com.wire.android.ui.home.conversations.messages.item.MessageClickActions
4242
import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem
43-
import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration
43+
import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration
4444
import com.wire.android.ui.home.conversations.mock.mockAssetAudioMessage
4545
import com.wire.android.ui.home.conversations.mock.mockAssetMessage
4646
import com.wire.android.ui.home.conversations.model.UIMessage
@@ -134,7 +134,7 @@ private fun AssetMessagesListContent(
134134
onSelfDeletingMessageRead = { },
135135
shouldDisplayMessageStatus = false,
136136
shouldDisplayFooter = false,
137-
swipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable,
137+
swipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable,
138138
failureInteractionAvailable = false,
139139
)
140140
}

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ fun MessageContainerItem(
3434
message: UIMessage,
3535
conversationDetailsData: ConversationDetailsData,
3636
clickActions: MessageClickActions,
37-
swipableMessageConfiguration: SwipableMessageConfiguration,
37+
swipeableMessageConfiguration: SwipeableMessageConfiguration,
3838
onSelfDeletingMessageRead: (UIMessage) -> Unit,
3939
modifier: Modifier = Modifier,
4040
searchQuery: String = "",
@@ -83,7 +83,7 @@ fun MessageContainerItem(
8383
clickActions = clickActions,
8484
showAuthor = showAuthor,
8585
assetStatus = assetStatus,
86-
swipableMessageConfiguration = swipableMessageConfiguration,
86+
swipeableMessageConfiguration = swipeableMessageConfiguration,
8787
failureInteractionAvailable = failureInteractionAvailable,
8888
searchQuery = searchQuery,
8989
shouldDisplayMessageStatus = shouldDisplayMessageStatus,

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt

+9-8
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ fun RegularMessageItem(
7575
searchQuery: String = "",
7676
showAuthor: Boolean = true,
7777
assetStatus: AssetTransferStatus? = null,
78-
swipableMessageConfiguration: SwipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable,
78+
swipeableMessageConfiguration: SwipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable,
7979
shouldDisplayMessageStatus: Boolean = true,
8080
shouldDisplayFooter: Boolean = true,
8181
failureInteractionAvailable: Boolean = true,
@@ -190,14 +190,15 @@ fun RegularMessageItem(
190190
}
191191
)
192192
}
193-
if (swipableMessageConfiguration is SwipableMessageConfiguration.SwipableToReply && isReplyable) {
194-
val onSwipe =
195-
remember(message) { { swipableMessageConfiguration.onSwipedToReply(message) } }
196-
SwipableToReplyBox(onSwipedToReply = onSwipe) {
197-
messageContent()
193+
194+
when (swipeableMessageConfiguration) {
195+
is SwipeableMessageConfiguration.Swipeable -> {
196+
SwipeableMessageBox(swipeableMessageConfiguration) {
197+
messageContent()
198+
}
198199
}
199-
} else {
200-
messageContent()
200+
201+
SwipeableMessageConfiguration.NotSwipeable -> messageContent()
201202
}
202203
}
203204

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt renamed to app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt

+117-36
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState
88
import androidx.compose.foundation.gestures.DraggableAnchors
99
import androidx.compose.foundation.gestures.Orientation
1010
import androidx.compose.foundation.gestures.anchoredDraggable
11+
import androidx.compose.foundation.gestures.animateTo
1112
import androidx.compose.foundation.layout.Arrangement
1213
import androidx.compose.foundation.layout.Box
1314
import androidx.compose.foundation.layout.Row
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
1718
import androidx.compose.material3.Icon
1819
import androidx.compose.runtime.Composable
1920
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.runtime.LaunchedEffect
2022
import androidx.compose.runtime.Stable
2123
import androidx.compose.runtime.getValue
2224
import androidx.compose.runtime.mutableStateOf
@@ -40,26 +42,60 @@ import androidx.compose.ui.unit.dp
4042
import com.wire.android.R
4143
import com.wire.android.ui.common.colorsScheme
4244
import com.wire.android.ui.common.dimensions
43-
import com.wire.android.ui.home.conversations.model.UIMessage
4445
import kotlin.math.absoluteValue
4546
import kotlin.math.min
4647

4748
@Stable
48-
sealed interface SwipableMessageConfiguration {
49-
data object NotSwipable : SwipableMessageConfiguration
50-
class SwipableToReply(val onSwipedToReply: (uiMessage: UIMessage.Regular) -> Unit) : SwipableMessageConfiguration
49+
sealed interface SwipeableMessageConfiguration {
50+
data object NotSwipeable : SwipeableMessageConfiguration
51+
class Swipeable(
52+
val onSwipedRight: (() -> Unit)? = null,
53+
val onSwipedLeft: (() -> Unit)? = null,
54+
) : SwipeableMessageConfiguration
5155
}
5256

5357
enum class SwipeAnchor {
5458
CENTERED,
55-
START_TO_END
59+
START_TO_END,
60+
END_TO_START,
5661
}
5762

63+
data class SwipeAction(
64+
val icon: Int,
65+
val action: () -> Unit,
66+
)
67+
68+
@Composable
69+
internal fun SwipeableMessageBox(
70+
configuration: SwipeableMessageConfiguration.Swipeable,
71+
modifier: Modifier = Modifier,
72+
content: @Composable () -> Unit,
73+
) {
74+
SwipeableBox(
75+
modifier = modifier,
76+
onSwipeRight = configuration.onSwipedRight?.let {
77+
SwipeAction(
78+
icon = R.drawable.ic_reply,
79+
action = it,
80+
)
81+
},
82+
onSwipeLeft = configuration.onSwipedLeft?.let {
83+
SwipeAction(
84+
icon = R.drawable.ic_react,
85+
action = it,
86+
)
87+
},
88+
content = content,
89+
)
90+
}
91+
92+
@Suppress("CyclomaticComplexMethod")
5893
@OptIn(ExperimentalFoundationApi::class)
5994
@Composable
60-
internal fun SwipableToReplyBox(
95+
private fun SwipeableBox(
6196
modifier: Modifier = Modifier,
62-
onSwipedToReply: () -> Unit = {},
97+
onSwipeRight: SwipeAction? = null,
98+
onSwipeLeft: SwipeAction? = null,
6399
content: @Composable () -> Unit
64100
) {
65101
val density = LocalDensity.current
@@ -86,55 +122,83 @@ internal fun SwipableToReplyBox(
86122
velocityThreshold = { screenWidth },
87123
snapAnimationSpec = tween(),
88124
decayAnimationSpec = splineBasedDecay(density),
89-
confirmValueChange = { changedValue ->
90-
if (changedValue == SwipeAnchor.START_TO_END) {
91-
// Attempt to finish dismiss, notify reply intention
92-
onSwipedToReply()
93-
}
94-
if (changedValue == SwipeAnchor.CENTERED) {
95-
// Reset the haptic feedback when drag is stopped
96-
didVibrateOnCurrentDrag = false
97-
}
98-
// Reject state change, only allow returning back to rest position
99-
changedValue == SwipeAnchor.CENTERED
100-
},
101125
anchors = DraggableAnchors {
126+
102127
SwipeAnchor.CENTERED at 0f
103-
SwipeAnchor.START_TO_END at screenWidth
128+
129+
if (onSwipeRight != null) {
130+
SwipeAnchor.START_TO_END at dragWidth
131+
}
132+
133+
if (onSwipeLeft != null) {
134+
SwipeAnchor.END_TO_START at -dragWidth
135+
}
104136
}
105137
)
106138
}
139+
140+
LaunchedEffect(dragState.settledValue) {
141+
when (dragState.settledValue) {
142+
SwipeAnchor.START_TO_END -> {
143+
onSwipeRight?.action?.invoke()
144+
dragState.animateTo(SwipeAnchor.CENTERED)
145+
}
146+
147+
SwipeAnchor.END_TO_START -> {
148+
onSwipeLeft?.action?.invoke()
149+
dragState.animateTo(SwipeAnchor.CENTERED)
150+
}
151+
152+
SwipeAnchor.CENTERED -> {}
153+
}
154+
didVibrateOnCurrentDrag = false
155+
}
156+
107157
val primaryColor = colorsScheme().primary
108158

109159
Box(
110160
modifier = modifier.fillMaxSize(),
111161
) {
162+
163+
val dragOffset = dragState.requireOffset()
164+
112165
// Drag indication
113166
Row(
114167
modifier = Modifier
115168
.matchParentSize()
116169
.drawBehind {
117-
// TODO(RTL): Might need adjusting once RTL is supported
118170
drawRect(
119171
color = primaryColor,
120-
topLeft = Offset(0f, 0f),
121-
size = Size(dragState.requireOffset().absoluteValue, size.height),
172+
topLeft = if (dragOffset >= 0f) {
173+
Offset(0f, 0f)
174+
} else {
175+
Offset(size.width - dragOffset.absoluteValue, 0f)
176+
},
177+
size = Size(dragOffset.absoluteValue, size.height),
122178
)
123179
},
124180
verticalAlignment = Alignment.CenterVertically,
125181
horizontalArrangement = Arrangement.Start
126182
) {
183+
184+
val dragProgress = dragState.offset.absoluteValue / dragWidth
185+
val adjustedProgress = min(1f, dragProgress)
186+
val progress = FastOutLinearInEasing.transform(adjustedProgress)
187+
188+
// Got to the end, user can release to perform action, so we vibrate to show it
189+
if (progress == 1f && !didVibrateOnCurrentDrag) {
190+
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
191+
didVibrateOnCurrentDrag = true
192+
}
193+
127194
if (dragState.offset > 0f) {
128-
val dragProgress = dragState.offset / dragWidth
129-
val adjustedProgress = min(1f, dragProgress)
130-
val progress = FastOutLinearInEasing.transform(adjustedProgress)
131-
// Got to the end, user can release to perform action, so we vibrate to show it
132-
if (progress == 1f && !didVibrateOnCurrentDrag) {
133-
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
134-
didVibrateOnCurrentDrag = true
195+
onSwipeRight?.let { action ->
196+
SwipeActionIcon(action.icon, screenWidth, dragWidth, density, progress)
197+
}
198+
} else if (dragState.offset < 0f) {
199+
onSwipeLeft?.let {
200+
SwipeActionIcon(it.icon, screenWidth, dragWidth, density, progress, false)
135201
}
136-
137-
ReplySwipeIcon(dragWidth, density, progress)
138202
}
139203
}
140204
// Message content, which is draggable
@@ -154,20 +218,37 @@ internal fun SwipableToReplyBox(
154218
}
155219

156220
@Composable
157-
private fun ReplySwipeIcon(dragWidth: Float, density: Density, progress: Float) {
221+
private fun SwipeActionIcon(
222+
resourceId: Int,
223+
screenWidth: Float,
224+
dragWidth: Float,
225+
density: Density,
226+
progress: Float,
227+
swipeRight: Boolean = true
228+
) {
158229
val midPointBetweenStartAndGestureEnd = dragWidth / 2
159230
val iconSize = dimensions().fabIconSize
160231
val targetIconAnchorPosition = midPointBetweenStartAndGestureEnd - with(density) { iconSize.toPx() / 2 }
161232
val xOffset = with(density) {
162233
val totalTravelDistance = iconSize.toPx() + targetIconAnchorPosition
163-
-iconSize.toPx() + (totalTravelDistance * progress)
234+
if (swipeRight) {
235+
(totalTravelDistance * progress) - iconSize.toPx()
236+
} else {
237+
(totalTravelDistance * progress) - iconSize.toPx() / 2
238+
}
164239
}
165240
Icon(
166-
painter = painterResource(id = R.drawable.ic_reply),
241+
painter = painterResource(id = resourceId),
167242
contentDescription = "",
168243
modifier = Modifier
169244
.size(iconSize)
170-
.offset { IntOffset(xOffset.toInt(), 0) },
245+
.offset {
246+
if (swipeRight) {
247+
IntOffset(xOffset.toInt(), 0)
248+
} else {
249+
IntOffset(screenWidth.toInt() - xOffset.toInt(), 0)
250+
}
251+
},
171252
tint = colorsScheme().onPrimary
172253
)
173254
}

0 commit comments

Comments
 (0)