Skip to content

Commit aa407db

Browse files
authored
New forward menu (#294)
1 parent ca57c10 commit aa407db

21 files changed

Lines changed: 1209 additions & 321 deletions

File tree

app/src/main/java/org/monogram/app/components/MobileLayout.kt

Lines changed: 158 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.monogram.app.components
22

33
import androidx.compose.animation.core.animate
4+
import androidx.compose.animation.core.animateFloatAsState
45
import androidx.compose.animation.core.spring
56
import androidx.compose.animation.core.tween
67
import androidx.compose.foundation.background
@@ -28,14 +29,13 @@ import androidx.compose.ui.input.pointer.pointerInput
2829
import androidx.compose.ui.input.pointer.util.VelocityTracker
2930
import androidx.compose.ui.layout.onSizeChanged
3031
import androidx.compose.ui.util.fastFirstOrNull
32+
import androidx.compose.ui.zIndex
33+
import com.arkivanov.decompose.Child
3134
import com.arkivanov.decompose.ExperimentalDecomposeApi
3235
import com.arkivanov.decompose.extensions.compose.stack.Children
33-
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
34-
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
35-
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
36-
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
37-
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
36+
import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation
3837
import com.arkivanov.decompose.extensions.compose.subscribeAsState
38+
import com.arkivanov.decompose.router.stack.ChildStack
3939
import kotlinx.coroutines.launch
4040
import org.monogram.presentation.root.RootComponent
4141
import kotlin.math.abs
@@ -46,23 +46,42 @@ fun MobileLayout(root: RootComponent) {
4646
val stack by root.childStack.subscribeAsState()
4747
val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState()
4848
val coroutineScope = rememberCoroutineScope()
49-
val previous = stack.items.dropLast(1).lastOrNull()?.instance
49+
val activeEntry = stack.active
50+
val previousEntry = stack.items.dropLast(1).lastOrNull()
51+
val stackKeysAreUnique = stack.items.map { it.key }.toSet().size == stack.items.size
52+
val canRenderSwipePreview =
53+
stackKeysAreUnique &&
54+
previousEntry != null &&
55+
previousEntry.key != activeEntry.key &&
56+
previousEntry.instance !== activeEntry.instance
5057
var dragOffsetX by remember { mutableFloatStateOf(0f) }
5158
var isCompletingSwipeBack by remember { mutableStateOf(false) }
5259
var widthPx by remember { mutableFloatStateOf(0f) }
5360
var isSwipeBackBlocked by remember { mutableStateOf(false) }
5461
val canUseDragToBack =
55-
isDragToBackEnabled && isSwipeBackSupported(stack.active.instance) && !isSwipeBackBlocked
62+
isDragToBackEnabled &&
63+
previousEntry != null &&
64+
stackKeysAreUnique &&
65+
isSwipeBackSupported(activeEntry.instance) &&
66+
!isSwipeBackBlocked
5667
val dragProgress = if (widthPx > 0f) (dragOffsetX / widthPx).coerceIn(0f, 1f) else 0f
5768

69+
LaunchedEffect(activeEntry.key, stackKeysAreUnique) {
70+
isSwipeBackBlocked = false
71+
if (!stackKeysAreUnique) {
72+
dragOffsetX = 0f
73+
isCompletingSwipeBack = false
74+
}
75+
}
76+
5877
LaunchedEffect(canUseDragToBack) {
5978
if (!canUseDragToBack && dragOffsetX > 0f) {
6079
dragOffsetX = 0f
6180
isCompletingSwipeBack = false
6281
}
6382
}
6483

65-
if (dragOffsetX > 0f && previous != null) {
84+
if (dragOffsetX > 0f && canRenderSwipePreview) {
6685
Box(modifier = Modifier.fillMaxSize()) {
6786
Box(
6887
modifier = Modifier
@@ -71,7 +90,12 @@ fun MobileLayout(root: RootComponent) {
7190
translationX = ((dragProgress - 1f) * widthPx * 0.08f)
7291
},
7392
) {
74-
RenderChild(previous)
93+
key("swipe-preview:${previousEntry.key}") {
94+
RenderChild(
95+
child = previousEntry.instance,
96+
isOverlay = true,
97+
)
98+
}
7599
}
76100
Box(
77101
modifier = Modifier
@@ -93,7 +117,7 @@ fun MobileLayout(root: RootComponent) {
93117
}
94118
.then(
95119
if (canUseDragToBack) {
96-
Modifier.pointerInput(canUseDragToBack) {
120+
Modifier.pointerInput(canUseDragToBack, activeEntry.key) {
97121
awaitEachGesture {
98122
if (size.width == 0) return@awaitEachGesture
99123

@@ -170,6 +194,11 @@ fun MobileLayout(root: RootComponent) {
170194
continue
171195
}
172196

197+
if (!canRenderSwipePreview) {
198+
dragOffsetX = 0f
199+
break
200+
}
201+
173202
isDragging = true
174203
}
175204

@@ -203,11 +232,9 @@ fun MobileLayout(root: RootComponent) {
203232
},
204233
) {
205234
Children(
206-
stack = root.childStack,
207-
animation = predictiveBackAnimation(
208-
backHandler = root.backHandler,
209-
onBack = root::onBack,
210-
fallbackAnimation = if (!isCompletingSwipeBack) stackAnimation(slide() + fade()) else null,
235+
stack = stack,
236+
animation = safeStackAnimation(
237+
enabled = dragOffsetX == 0f && !isCompletingSwipeBack,
211238
),
212239
) { child ->
213240
key(child.key) {
@@ -254,4 +281,119 @@ private fun isSwipeBackSupported(child: RootComponent.Child): Boolean =
254281
is RootComponent.Child.DebugChild -> true
255282

256283
else -> false
257-
}
284+
}
285+
286+
private fun <C : Any, T : Any> activeOnlyStackAnimation(): StackAnimation<C, T> =
287+
StackAnimation { stack, modifier, content ->
288+
Box(modifier = modifier) {
289+
content(stack.active)
290+
}
291+
}
292+
293+
@Composable
294+
private fun <C : Any, T : Any> safeStackAnimation(enabled: Boolean): StackAnimation<C, T> {
295+
if (!enabled) {
296+
return activeOnlyStackAnimation()
297+
}
298+
299+
return StackAnimation { stack, modifier, content ->
300+
var previousStack by remember { mutableStateOf<ChildStack<C, T>?>(null) }
301+
var transition by remember { mutableStateOf<StackTransition<C, T>?>(null) }
302+
val oldStack = previousStack
303+
304+
if (oldStack == null) {
305+
previousStack = stack
306+
} else if (oldStack.active.key != stack.active.key) {
307+
val oldActive = oldStack.active
308+
val newActive = stack.active
309+
transition =
310+
if (oldActive.key != newActive.key) {
311+
StackTransition(
312+
oldActive = oldActive,
313+
newActive = newActive,
314+
isPop = stack.items.size < oldStack.items.size,
315+
)
316+
} else {
317+
null
318+
}
319+
previousStack = stack
320+
}
321+
322+
val currentTransition = transition
323+
val animationProgress by animateFloatAsState(
324+
targetValue = if (currentTransition == null) 1f else 0f,
325+
animationSpec = tween(durationMillis = 220),
326+
label = "MobileStackAnimation",
327+
finishedListener = {
328+
transition = null
329+
},
330+
)
331+
332+
Box(modifier = modifier) {
333+
if (
334+
currentTransition != null &&
335+
currentTransition.oldActive.key != currentTransition.newActive.key
336+
) {
337+
StackAnimatedChild(
338+
child = currentTransition.oldActive,
339+
progress = animationProgress,
340+
isPop = currentTransition.isPop,
341+
isOutgoing = true,
342+
content = content,
343+
)
344+
StackAnimatedChild(
345+
child = currentTransition.newActive,
346+
progress = animationProgress,
347+
isPop = currentTransition.isPop,
348+
isOutgoing = false,
349+
content = content,
350+
)
351+
} else {
352+
content(stack.active)
353+
}
354+
}
355+
}
356+
}
357+
358+
@Composable
359+
private fun <C : Any, T : Any> StackAnimatedChild(
360+
child: Child.Created<C, T>,
361+
progress: Float,
362+
isPop: Boolean,
363+
isOutgoing: Boolean,
364+
content: @Composable (child: Child.Created<C, T>) -> Unit,
365+
) {
366+
val direction = if (isPop) -1f else 1f
367+
val translationFactor =
368+
if (isOutgoing) {
369+
-direction * 0.08f * (1f - progress)
370+
} else {
371+
direction * progress
372+
}
373+
val alpha =
374+
if (isOutgoing) {
375+
1f - 0.18f * (1f - progress)
376+
} else {
377+
1f
378+
}
379+
380+
key("stack-animation:${child.key}:${if (isOutgoing) "out" else "in"}") {
381+
Box(
382+
modifier = Modifier
383+
.fillMaxSize()
384+
.zIndex(if (isOutgoing) 0f else 1f)
385+
.graphicsLayer {
386+
translationX = size.width * translationFactor
387+
this.alpha = alpha
388+
},
389+
) {
390+
content(child)
391+
}
392+
}
393+
}
394+
395+
private data class StackTransition<C : Any, T : Any>(
396+
val oldActive: Child.Created<C, T>,
397+
val newActive: Child.Created<C, T>,
398+
val isPop: Boolean,
399+
)

data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ interface ChatLocalDataSource {
2020
fun getMessagesForChat(chatId: Long): Flow<List<MessageEntity>>
2121
suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List<MessageEntity>
2222
suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List<MessageEntity>
23-
suspend fun getMessage(chatId: Long, messageId: Long): MessageEntity?
2423
suspend fun getLatestMessages(chatId: Long, limit: Int): List<MessageEntity>
2524
suspend fun getMessagesByIds(chatId: Long, messageIds: List<Long>): List<MessageEntity>
2625
suspend fun insertMessage(message: MessageEntity)

data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,6 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
8888
.take(limit)
8989
}
9090

91-
override suspend fun getMessage(chatId: Long, messageId: Long): MessageEntity? {
92-
return messages[chatId]?.value?.get(messageId)
93-
}
94-
9591
override suspend fun getLatestMessages(chatId: Long, limit: Int): List<MessageEntity> {
9692
val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
9793
return chatMessages.sortedByDescending { it.date }.take(limit)

data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,6 @@ class RoomChatLocalDataSource(
5151

5252
override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int) = messageDao.getMessagesNewer(chatId, fromMessageId, limit)
5353

54-
override suspend fun getMessage(chatId: Long, messageId: Long) =
55-
messageDao.getMessage(chatId, messageId)
56-
5754
override suspend fun getLatestMessages(chatId: Long, limit: Int) = messageDao.getLatestMessages(chatId, limit)
5855

5956
override suspend fun getMessagesByIds(chatId: Long, messageIds: List<Long>) =

data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,31 @@ interface MessageRemoteDataSource {
133133
threadId: Long?,
134134
sendOptions: MessageSendOptions
135135
): TdApi.Messages?
136-
suspend fun sendVideoNote(chatId: Long, videoPath: String, duration: Int, length: Int): TdApi.Message?
137-
suspend fun sendVoiceNote(chatId: Long, voicePath: String, duration: Int, waveform: ByteArray): TdApi.Message?
138-
suspend fun forwardMessages(toChatId: Long, fromChatId: Long, messageIds: LongArray, removeCaption: Boolean, sendCopy: Boolean): TdApi.Messages?
136+
suspend fun sendVideoNote(
137+
chatId: Long,
138+
videoPath: String,
139+
duration: Int,
140+
length: Int,
141+
replyToMsgId: Long?,
142+
threadId: Long?
143+
): TdApi.Message?
144+
145+
suspend fun sendVoiceNote(
146+
chatId: Long,
147+
voicePath: String,
148+
duration: Int,
149+
waveform: ByteArray,
150+
replyToMsgId: Long?,
151+
threadId: Long?
152+
): TdApi.Message?
153+
suspend fun forwardMessages(
154+
toChatId: Long,
155+
fromChatId: Long,
156+
messageIds: LongArray,
157+
forumTopicId: Int? = null,
158+
removeCaption: Boolean,
159+
sendCopy: Boolean
160+
): TdApi.Messages?
139161
suspend fun deleteMessages(chatId: Long, messageIds: LongArray, revoke: Boolean): TdApi.Ok?
140162
suspend fun editMessageText(chatId: Long, messageId: Long, text: String, entities: List<MessageEntity>): TdApi.Message?
141163
suspend fun editMessageCaption(chatId: Long, messageId: Long, caption: String, entities: List<MessageEntity>): TdApi.Message?

data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -864,14 +864,25 @@ class TdMessageRemoteDataSource(
864864
return result
865865
}
866866

867-
override suspend fun sendVideoNote(chatId: Long, videoPath: String, duration: Int, length: Int): TdApi.Message? {
867+
override suspend fun sendVideoNote(
868+
chatId: Long,
869+
videoPath: String,
870+
duration: Int,
871+
length: Int,
872+
replyToMsgId: Long?,
873+
threadId: Long?
874+
): TdApi.Message? {
868875
val content = TdApi.InputMessageVideoNote().apply {
869876
this.videoNote = TdApi.InputFileLocal(videoPath)
870877
this.duration = duration
871878
this.length = length
872879
}
880+
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
881+
val topicId = resolveTopicId(chatId, threadId)
873882
val req = TdApi.SendMessage().apply {
874883
this.chatId = chatId
884+
this.topicId = topicId
885+
this.replyTo = replyTo
875886
this.inputMessageContent = content
876887
}
877888
val response = safeExecute(req)
@@ -883,31 +894,45 @@ class TdMessageRemoteDataSource(
883894
chatId: Long,
884895
voicePath: String,
885896
duration: Int,
886-
waveform: ByteArray
897+
waveform: ByteArray,
898+
replyToMsgId: Long?,
899+
threadId: Long?
887900
): TdApi.Message? {
888901
val content = TdApi.InputMessageVoiceNote().apply {
889902
this.voiceNote = TdApi.InputFileLocal(voicePath)
890903
this.duration = duration
891904
this.waveform = waveform
892905
}
906+
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
907+
val topicId = resolveTopicId(chatId, threadId)
893908
val req = TdApi.SendMessage().apply {
894909
this.chatId = chatId
910+
this.topicId = topicId
911+
this.replyTo = replyTo
895912
this.inputMessageContent = content
896913
}
897914
val response = safeExecute(req)
898915
if (response?.content is TdApi.MessageVoiceNote) waitForUpload((response.content as TdApi.MessageVoiceNote).voiceNote.voice.id).await()
899916
return response
900917
}
901918

902-
override suspend fun forwardMessages(toChatId: Long, fromChatId: Long, messageIds: LongArray, removeCaption: Boolean, sendCopy: Boolean): TdApi.Messages? {
919+
override suspend fun forwardMessages(
920+
toChatId: Long,
921+
fromChatId: Long,
922+
messageIds: LongArray,
923+
forumTopicId: Int?,
924+
removeCaption: Boolean,
925+
sendCopy: Boolean
926+
): TdApi.Messages? {
903927
val options = TdApi.MessageSendOptions().apply {
904928
this.disableNotification = false
905929
this.fromBackground = false
906930
}
907931
val req = TdApi.ForwardMessages().apply {
908932
this.chatId = toChatId
933+
this.topicId = forumTopicId?.let { TdApi.MessageTopicForum(it) }
909934
this.fromChatId = fromChatId
910-
this.messageIds = messageIds
935+
this.messageIds = messageIds.sortedArray()
911936
this.options = options
912937
this.removeCaption = removeCaption
913938
this.sendCopy = sendCopy

data/src/main/java/org/monogram/data/db/dao/MessageDao.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ interface MessageDao {
1818
@Query("SELECT * FROM messages WHERE chatId = :chatId AND id > :fromMessageId ORDER BY date ASC LIMIT :limit")
1919
suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List<MessageEntity>
2020

21-
@Query("SELECT * FROM messages WHERE chatId = :chatId AND id = :messageId")
22-
suspend fun getMessage(chatId: Long, messageId: Long): MessageEntity?
23-
2421
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :limit")
2522
suspend fun getLatestMessages(chatId: Long, limit: Int): List<MessageEntity>
2623

0 commit comments

Comments
 (0)