diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt index 7d9afbd0be..2eb1dced2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first /** * Asks a [MediaKeyboardScaffold] for a keyboard. @@ -32,6 +34,14 @@ class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) { var isSystemKeyboardVisible: Boolean by mutableStateOf(false) internal set + /** + * True while the system keyboard is on its way in or out. [isSystemKeyboardVisible] reads the + * target of that animation, so it goes false the moment a hide is asked for, well before the + * space is handed back. + */ + var isSystemKeyboardAnimating: Boolean by mutableStateOf(false) + internal set + /** * True while the system keyboard has been asked for in place of one of ours but has yet to settle. * Holds the space across that gap, which the IME service round trip would otherwise leave empty. @@ -45,6 +55,18 @@ class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) { val isShowing: Boolean get() = current != null + /** True when no keyboard is up, on its way out, or being held space for. */ + val isSettled: Boolean get() = current == null && !isSystemKeyboardVisible && !isSystemKeyboardAnimating && !awaitingSystemKeyboard + + /** + * Suspends until [isSettled], so a caller can act on a content area that has been handed all of + * its space back. Reads the same snapshot state the scaffold writes, so there is no settle to miss + * for a keyboard that goes away without animating. + */ + suspend fun awaitSettled() { + snapshotFlow { isSettled }.first { it } + } + fun show(key: MediaKeyboardKey) { current = key awaitingSystemKeyboard = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt index 7f8a8af7dc..f530677626 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -122,10 +123,17 @@ fun MediaKeyboardScaffold( // The target state, so it does not read as hidden for the whole closing animation. val systemKeyboardVisible = WindowInsets.isImeVisible + val systemKeyboardAnimating = imeAnimationSource.getBottom(density) != imeAnimationTarget.getBottom(density) + + // Written together, so nothing waiting on the controller can see a keyboard that is neither + // visible nor still animating out. + SideEffect { + controller.isSystemKeyboardVisible = systemKeyboardVisible + controller.isSystemKeyboardAnimating = systemKeyboardAnimating + } var hasReportedKeyboardVisibility by remember { mutableStateOf(false) } LaunchedEffect(systemKeyboardVisible) { - controller.isSystemKeyboardVisible = systemKeyboardVisible if (hasReportedKeyboardVisibility) { onEvent(MediaKeyboardEvents.SystemKeyboardVisibilityChanged(systemKeyboardVisible)) } @@ -224,13 +232,7 @@ fun MediaKeyboardScaffold( } val systemKeyboardTakingOverSpace = activeKey == null && - ( - controller.awaitingSystemKeyboard || - ( - imeAnimationTarget.getBottom(density) > 0 && - imeAnimationSource.getBottom(density) != imeAnimationTarget.getBottom(density) - ) - ) + (controller.awaitingSystemKeyboard || (imeAnimationTarget.getBottom(density) > 0 && systemKeyboardAnimating)) val claimedBottomPx = { if (activeKey != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt index 156ee3c1fc..4b6dffd51e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt @@ -123,6 +123,20 @@ class ChatInputController( } } + /** + * Like [runAfterAllHidden], but suspends until the keyboards have finished animating out rather + * than returning as soon as the hide has been asked for. For callers that measure themselves + * against the content area, which stays shrunk for the length of that animation. + */ + suspend fun hideAllAndAwaitSettled(imeTarget: EditText) { + if (controller.isSettled) { + return + } + + hideAll(imeTarget) + controller.awaitSettled() + } + /** * @param key The keyboard to bring up, or take away if already showing. * @param imeTarget The field the system keyboard belongs to. diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 930444dc48..aadb36214b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -4115,11 +4115,6 @@ class ConversationFragment : (!recipient.isGroup || recipient.isActiveGroup) && adapter.selectedItems.isEmpty() ) { - multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage)) - binding.conversationItemRecycler.invalidateItemDecorations() - binding.reactionsShade.visibility = View.VISIBLE - binding.conversationItemRecycler.suppressLayout(true) - val target: InteractiveConversationElement? = if (itemView is InteractiveConversationElement) { itemView } else { @@ -4132,103 +4127,15 @@ class ConversationFragment : } if (target != null) { - val audioUri = messageRecord.getAudioUriForLongClick() - if (audioUri != null) { - getVoiceNoteMediaController().pausePlayback(audioUri) - } - - val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler) - var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null - var videoBitmap: Bitmap? = null - if (childAdapterPosition != RecyclerView.NO_POSITION) { - mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition) - if (mp4Holder?.isVisible == true) { - mp4Holder.pause() - videoBitmap = mp4Holder.bitmap - mp4Holder.hide() - } - } - - val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap) - + // Read before anything is asked to hide, or the keyboard cannot be brought back on dismiss. val focusedView = if (container.isInputShowing || !container.isKeyboardShowing) null else itemView.rootView.findFocus() - val bodyBubble = target.bubbleView - val selectedConversationModel = SelectedConversationModel( - bitmap = snapshot, - itemX = itemView.x, - itemY = itemView.y + binding.conversationItemRecycler.translationY, - bubbleY = bodyBubble.y, - bubbleWidth = bodyBubble.width, - audioUri = audioUri, - isOutgoing = messageRecord.isOutgoing, - focusedView = focusedView, - snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics( - snapshotOffset = bodyBubble.x, - contextMenuPadding = bodyBubble.x - ) - ) - bodyBubble.visibility = View.INVISIBLE - target.reactionsView.visibility = View.INVISIBLE - - val quotedIndicatorVisible = target.quotedIndicatorView?.visibility == View.VISIBLE - if (quotedIndicatorVisible) { - ViewUtil.fadeOut(target.quotedIndicatorView!!, 150, View.INVISIBLE) + // The overlay sizes itself to the content area, so every keyboard has to be all the way + // out before it measures. Mid-animation it has half a screen to fit the menu into. + viewLifecycleOwner.lifecycleScope.launch { + container.hideAllAndAwaitSettled(composeText) + showReactionOverlay(itemView, item, target, focusedView) } - - container.hideKeyboard(composeText) - - viewModel.setHideScrollButtonsForReactionOverlay(true) - - val targetViews: InteractiveConversationElement = target - handleReaction( - item.conversationMessage, - ReactionsToolbarListener(item.conversationMessage), - selectedConversationModel, - object : OnHideListener { - override fun startHide(focusedView: View?) { - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { - return - } - - multiselectItemDecoration.hideShade(binding.conversationItemRecycler) - ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) - - if (focusedView == composeText || searchMenuItem?.isActionViewExpanded == true) { - container.showSoftkey(composeText) - } - } - - override fun onHide() { - viewModel.setIsReactionDelegateShowing(false) - - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { - return - } - - binding.conversationItemRecycler.suppressLayout(false) - if (selectedConversationModel.audioUri != null) { - getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id) - } - - clearFocusedItem() - - if (mp4Holder != null) { - mp4Holder.show() - mp4Holder.resume() - } - - bodyBubble.visibility = View.VISIBLE - targetViews.reactionsView.visibility = View.VISIBLE - - if (quotedIndicatorVisible && targetViews.quotedIndicatorView != null) { - ViewUtil.fadeIn(targetViews.quotedIndicatorView!!, 150) - } - - viewModel.setHideScrollButtonsForReactionOverlay(false) - } - } - ) } } else if (item.conversationMessage.isActiveCollapsedHead) { viewModel.onExpandEvents(item.conversationMessage.messageRecord.id) @@ -4239,6 +4146,127 @@ class ConversationFragment : } } + /** + * Snapshots [target] and hands it to the reaction overlay. Split out of [onItemLongClick] because + * it runs once the keyboards are out of the way, which is not until a few frames later. + */ + private fun showReactionOverlay( + itemView: View, + item: MultiselectPart, + target: InteractiveConversationElement, + focusedView: View? + ) { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return + } + + val messageRecord = item.getMessageRecord() + + // The wait gave the list room to move on, so the row may be gone or bound to another message. + if (isActionModeStarted() || adapter.selectedItems.isNotEmpty() || target.conversationMessage.messageRecord.id != messageRecord.id) { + return + } + + multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage)) + binding.conversationItemRecycler.invalidateItemDecorations() + binding.reactionsShade.visibility = View.VISIBLE + binding.conversationItemRecycler.suppressLayout(true) + + val audioUri = messageRecord.getAudioUriForLongClick() + if (audioUri != null) { + getVoiceNoteMediaController().pausePlayback(audioUri) + } + + val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler) + var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null + var videoBitmap: Bitmap? = null + if (childAdapterPosition != RecyclerView.NO_POSITION) { + mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition) + if (mp4Holder?.isVisible == true) { + mp4Holder.pause() + videoBitmap = mp4Holder.bitmap + mp4Holder.hide() + } + } + + val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap) + + val bodyBubble = target.bubbleView + val selectedConversationModel = SelectedConversationModel( + bitmap = snapshot, + itemX = itemView.x, + itemY = itemView.y + binding.conversationItemRecycler.translationY, + bubbleY = bodyBubble.y, + bubbleWidth = bodyBubble.width, + audioUri = audioUri, + isOutgoing = messageRecord.isOutgoing, + focusedView = focusedView, + snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics( + snapshotOffset = bodyBubble.x, + contextMenuPadding = bodyBubble.x + ) + ) + + bodyBubble.visibility = View.INVISIBLE + target.reactionsView.visibility = View.INVISIBLE + + val quotedIndicatorVisible = target.quotedIndicatorView?.visibility == View.VISIBLE + if (quotedIndicatorVisible) { + ViewUtil.fadeOut(target.quotedIndicatorView!!, 150, View.INVISIBLE) + } + + viewModel.setHideScrollButtonsForReactionOverlay(true) + + handleReaction( + item.conversationMessage, + ReactionsToolbarListener(item.conversationMessage), + selectedConversationModel, + object : OnHideListener { + override fun startHide(focusedView: View?) { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return + } + + multiselectItemDecoration.hideShade(binding.conversationItemRecycler) + ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) + + if (focusedView == composeText || searchMenuItem?.isActionViewExpanded == true) { + container.showSoftkey(composeText) + } + } + + override fun onHide() { + viewModel.setIsReactionDelegateShowing(false) + + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return + } + + binding.conversationItemRecycler.suppressLayout(false) + if (selectedConversationModel.audioUri != null) { + getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id) + } + + clearFocusedItem() + + if (mp4Holder != null) { + mp4Holder.show() + mp4Holder.resume() + } + + bodyBubble.visibility = View.VISIBLE + target.reactionsView.visibility = View.VISIBLE + + if (quotedIndicatorVisible && target.quotedIndicatorView != null) { + ViewUtil.fadeIn(target.quotedIndicatorView!!, 150) + } + + viewModel.setHideScrollButtonsForReactionOverlay(false) + } + } + ) + } + override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) { GroupDescriptionDialog.show(childFragmentManager, groupName, description, shouldLinkifyWebLinks) }