Fix regression in long press menu heihgt.

This commit is contained in:
Alex Hart
2026-09-08 10:18:55 -03:00
parent f435e156c7
commit d015cd8355
4 changed files with 173 additions and 107 deletions
@@ -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
@@ -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) {
@@ -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.
@@ -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)
}