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 908ce679d6..f16aa5f111 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,8 +11,6 @@ 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. @@ -30,18 +28,10 @@ class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) { var current: MediaKeyboardKey? by mutableStateOf(null) private set - /** Whether the system keyboard is up, as reported by the target of the IME inset animation. */ + /** Whether the system keyboard is up, from the target of the IME inset animation. */ 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. @@ -55,18 +45,6 @@ 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 111358d352..7a4494c686 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 @@ -33,6 +33,7 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -132,11 +133,16 @@ fun MediaKeyboardScaffold( derivedStateOf { imeInsets.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 + } + + // The controller outlives us. current is left set so a rebuilt view restores its keyboard. + DisposableEffect(controller) { + onDispose { + controller.isSystemKeyboardVisible = false + controller.awaitingSystemKeyboard = false + } } var hasReportedKeyboardVisibility by remember { mutableStateOf(false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 74e7e4d641..46fc796f47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -204,10 +204,15 @@ public final class ConversationReactionOverlay extends FrameLayout { @NonNull PointF lastSeenDownPoint, boolean isMessageOnLeft) { + // A hide can land between show() and this layout pass. + if (overlayState == OverlayState.HIDDEN) { + return; + } + contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage)); - conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset()); - conversationItem.setY(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight); + conversationItem.setX(selectedConversationModel.getBubbleX()); + conversationItem.setY(selectedConversationModel.getBubbleY()); Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); @@ -215,7 +220,7 @@ public final class ConversationReactionOverlay extends FrameLayout { int overlayHeight = getHeight() - bottomNavigationBarHeight; int bubbleWidth = selectedConversationModel.getBubbleWidth(); - float endX = selectedConversationModel.getSnapshotMetrics().getSnapshotOffset(); + float endX = selectedConversationModel.getBubbleX(); float endY = conversationItem.getY(); float endApparentTop = endY; float endScale = 1f; @@ -251,8 +256,8 @@ public final class ConversationReactionOverlay extends FrameLayout { boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight; if (everythingFitsVertically) { - float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight; + float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); + boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight; if (menuFitsBelowItem) { if (conversationItem.getY() < 0) { @@ -289,8 +294,8 @@ public final class ConversationReactionOverlay extends FrameLayout { boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight; if (fitsVertically) { - float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight; + float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); + boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight; if (menuFitsBelowItem) { reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; @@ -346,7 +351,7 @@ public final class ConversationReactionOverlay extends FrameLayout { float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding; contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight())); } else { - float contentX = selectedConversationModel.getSnapshotMetrics().getContextMenuPadding(); + float contentX = selectedConversationModel.getContextMenuX(); float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth; float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale); @@ -423,6 +428,10 @@ public final class ConversationReactionOverlay extends FrameLayout { if (onHideListener != null) { onHideListener.onHide(); } + + if (overlayState == OverlayState.HIDDEN) { + releaseSelection(); + } } }); @@ -431,6 +440,13 @@ public final class ConversationReactionOverlay extends FrameLayout { } } + /** Drops the snapshot bitmap, model and menu the last long press left behind. */ + private void releaseSelection() { + selectedConversationModel = null; + contextMenu = null; + conversationItem.setBackground(null); + } + public boolean isShowing() { return overlayState != OverlayState.HIDDEN; } @@ -843,16 +859,21 @@ public final class ConversationReactionOverlay extends FrameLayout { itemScaleYAnim.setDuration(duration); animators.add(itemScaleYAnim); + // Where the row is now, not where the press started. Null once it is gone. + PointF returnPosition = selectedConversationModel.getReturnPosition().get(); + float returnX = returnPosition != null ? returnPosition.x : selectedConversationModel.getBubbleX(); + float returnY = returnPosition != null ? returnPosition.y : selectedConversationModel.getBubbleY(); + ObjectAnimator itemXAnim = new ObjectAnimator(); itemXAnim.setProperty(View.X); - itemXAnim.setFloatValues(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset()); + itemXAnim.setFloatValues(returnX); itemXAnim.setTarget(conversationItem); itemXAnim.setDuration(duration); animators.add(itemXAnim); ObjectAnimator itemYAnim = new ObjectAnimator(); itemYAnim.setProperty(View.Y); - itemYAnim.setFloatValues(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight); + itemYAnim.setFloatValues(returnY); itemYAnim.setTarget(conversationItem); itemYAnim.setDuration(duration); animators.add(itemYAnim); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt index d47a9b810a..d41159cb37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt @@ -1,22 +1,35 @@ package org.thoughtcrime.securesms.conversation import android.graphics.Bitmap +import android.graphics.PointF import android.net.Uri import android.view.View -import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement /** * Contains information on a single selected conversation item. This is used when transitioning * between selected and unselected states. + * + * Coordinates are in the reaction overlay's space, not the list's. + * + * @param bubbleX Left edge of the captured snapshot. + * @param bubbleY Top edge of the captured bubble. + * @param contextMenuX Left edge the context menu lines up with. */ data class SelectedConversationModel( val bitmap: Bitmap, - val itemX: Float, - val itemY: Float, + val bubbleX: Float, val bubbleY: Float, val bubbleWidth: Int, + val contextMenuX: Float, val audioUri: Uri? = null, val isOutgoing: Boolean, val focusedView: View?, - val snapshotMetrics: InteractiveConversationElement.SnapshotMetrics -) + val returnPosition: ReturnPosition +) { + + /** Where the snapshot animates back to, read on dismiss so a list that moved is followed. */ + fun interface ReturnPosition { + /** @return The row's current position, or null if it is gone. */ + fun get(): PointF? + } +} 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 df14bad5d1..ab48db4976 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 @@ -8,15 +8,9 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.view.View import android.widget.EditText -import kotlinx.coroutines.withTimeoutOrNull import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.awaitAfterNextLayout -import kotlin.time.Duration.Companion.milliseconds - -/** Longest a keyboard's exit is waited on. Comfortably past the platform's own hide animation. */ -private val SETTLE_TIMEOUT = 500.milliseconds /** * Adapts [MediaKeyboardController] to the conversation's view code, which asks for keyboards from @@ -35,6 +29,9 @@ class ChatInputController( private val listeners: MutableSet = mutableSetOf() private val keyboardStateListeners: MutableSet = mutableSetOf() + /** What [runAfterAllHidden] is waiting on. */ + private var pendingHiddenAction: (() -> Unit)? = null + val isInputShowing: Boolean get() = controller.isShowing @@ -61,12 +58,17 @@ class ChatInputController( fun clearListeners() { listeners.clear() keyboardStateListeners.clear() + pendingHiddenAction = null } fun onKeyboardVisibilityChanged(visible: Boolean) { keyboardStateListeners.toList().forEach { if (visible) it.onKeyboardShown() else it.onKeyboardHidden() } + + if (!visible) { + runPendingHiddenAction() + } } fun onKeyboardAnimationEnded() { @@ -79,6 +81,7 @@ class ChatInputController( fun onInputHidden() { listeners.toList().forEach { it.onInputHidden() } + runPendingHiddenAction() } /** @param imeTarget The field to bring the system keyboard up for, which need not be the input panel's. */ @@ -106,54 +109,25 @@ class ChatInputController( ViewUtil.hideKeyboard(context, imeTarget) } - fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) { - if (isInputShowing || isKeyboardShowing) { - val listener = object : Listener, KeyboardStateListener { - override fun onInputHidden() { - onHidden() - removeInputListener(this) - removeKeyboardStateListener(this) - } - - override fun onKeyboardHidden() { - onHidden() - removeInputListener(this) - removeKeyboardStateListener(this) - } - - override fun onInputShown(key: MediaKeyboardKey) = Unit - override fun onKeyboardShown() = Unit - } - - addInputListener(listener) - addKeyboardStateListener(listener) - hideAll(imeTarget) - } else { - onHidden() - } - } - /** - * 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. - * - * Gives up after [SETTLE_TIMEOUT]. A keyboard that never reports its exit should leave a caller - * measuring against a stale content area, not stranded. - * - * @param contentView The area the keyboards resize. The settle itself lands in the middle of an - * inset dispatch, a frame before the space is handed back, so this is waited on as well. + * Runs [onHidden] once whatever is up has reported itself away, or right now if nothing is. + * Only one action is queued at a time; a second call replaces the first. */ - suspend fun hideAllAndAwaitSettled(imeTarget: EditText, contentView: View) { - if (controller.isSettled) { + fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) { + if (!isInputShowing && !isKeyboardShowing) { + onHidden() return } + pendingHiddenAction = onHidden hideAll(imeTarget) - withTimeoutOrNull(SETTLE_TIMEOUT) { - controller.awaitSettled() - contentView.awaitAfterNextLayout() - } + } + + private fun runPendingHiddenAction() { + val action = pendingHiddenAction ?: return + + pendingHiddenAction = null + action() } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt index c2bfe8068f..d4bd829174 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -9,15 +9,19 @@ import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -50,6 +54,7 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f * @param isBubble Whether we're displaying content in a bubble * @param backgroundView The chat wallpaper * @param contentView The area that actually moves up when the keyboards appear + * @param overlayView The long press overlay, which a keyboard neither covers nor resizes */ @Composable fun ChatScreen( @@ -59,6 +64,7 @@ fun ChatScreen( isBubble: Boolean, backgroundView: View, contentView: View, + overlayView: View, modifier: Modifier = Modifier ) { val minimumHeight = dimensionResource(R.dimen.default_custom_keyboard_size) @@ -137,5 +143,14 @@ fun ChatScreen( modifier = Modifier.fillMaxSize() ) } + + // Above the scaffold so a closing keyboard neither covers nor resizes it. The bottom inset is + // left on; the overlay subtracts the navigation bar itself. + AndroidView( + factory = { overlayView }, + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars.add(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))) + ) } } 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 02bf5cc93d..4354f46da9 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 @@ -19,6 +19,7 @@ import android.content.IntentFilter import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color +import android.graphics.PointF import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.graphics.Rect @@ -99,7 +100,6 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -267,6 +267,7 @@ import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding +import org.thoughtcrime.securesms.databinding.V2ConversationOverlayBinding import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.GroupCallPeekEvent import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration @@ -371,6 +372,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil.getEditMessageThre import org.thoughtcrime.securesms.util.MessageConstraintsUtil.isValidEditMessageSend import org.thoughtcrime.securesms.util.MessageUtil import org.thoughtcrime.securesms.util.PlayStoreUtil +import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SignalLocalMetrics import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -473,6 +475,7 @@ class ConversationFragment : private val disposables = LifecycleDisposable() private val backgroundBinding by ViewBinderDelegate(bindingFactory = { V2ConversationBackgroundBinding.bind(conversationBackground) }) + private val overlayBinding by ViewBinderDelegate(bindingFactory = { V2ConversationOverlayBinding.bind(conversationOverlay) }) private val binding by ViewBinderDelegate(bindingFactory = { V2ConversationFragmentBinding.bind(conversationContent) }, onBindingWillBeDestroyed = { _binding -> _binding.conversationInputPanel.embeddedTextEditor.apply { setOnEditorActionListener(null) @@ -630,6 +633,9 @@ class ConversationFragment : /** The wallpaper, drawn behind [conversationContent]. */ private lateinit var conversationBackground: View + /** The long press overlay, drawn above [conversationContent]. */ + private lateinit var conversationOverlay: View + private val chatScreenViewModel: ChatScreenViewModel by viewModels() /** Stable across recomposition, so view code can ask for a keyboard from a click listener. */ @@ -672,25 +678,8 @@ class ConversationFragment : private val scheduledMessagesStub: Stub by lazy { Stub(binding.scheduledMessagesStub) } - /** - * The long press waiting on the keyboards to clear before the overlay can measure itself. The list - * has to stop taking taps for that whole wait, or a tap lands on a message that is about to be - * covered by the overlay. - */ - private var pendingReactionOverlayJob: Job? = null - - private val isReactionOverlayPending: Boolean - get() = pendingReactionOverlayJob?.isActive == true - - /** Swallows list touches for the length of [pendingReactionOverlayJob]. */ - private val pendingReactionOverlayTouchGuard = object : RecyclerView.OnItemTouchListener { - override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean = isReactionOverlayPending - override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) = Unit - override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit - } - private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) { - val conversationReactionStub = Stub(binding.conversationReactionScrubberStub) + val conversationReactionStub = Stub(overlayBinding.conversationReactionScrubberStub) val delegate = ConversationReactionDelegate(conversationReactionStub) delegate.setOnReactionSelectedListener(OnReactionsSelectedListener()) @@ -718,6 +707,7 @@ class ConversationFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false) conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false) + conversationOverlay = inflater.inflate(R.layout.v2_conversation_overlay, container, false) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -729,13 +719,29 @@ class ConversationFragment : scrims = chatScrims, isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE, backgroundView = conversationBackground, - contentView = conversationContent + contentView = conversationContent, + overlayView = conversationOverlay ) } } } } + /** + * Where [target]'s row sits in the overlay's coordinate space. [Projection] walks the layout + * positions; translations are not part of that walk, so they are added here. + */ + private fun overlayOriginOf(target: InteractiveConversationElement, recycler: RecyclerView): PointF { + val projection = Projection.relativeToViewWithCommonRoot(target.root, conversationOverlay, null) + val origin = PointF( + projection.x + target.root.translationX, + projection.y + target.root.translationY + recycler.translationY + ) + projection.release() + + return origin + } + private fun onMediaKeyboardEvent(event: MediaKeyboardEvents) { when (event) { is MediaKeyboardEvents.SystemKeyboardVisibilityChanged -> container.onKeyboardVisibilityChanged(event.visible) @@ -919,10 +925,6 @@ class ConversationFragment : override fun onPause() { super.onPause() - // Abandoned rather than resumed on the way back in, where the long press is no longer the last - // thing the user did. - pendingReactionOverlayJob?.cancel() - ConversationUtil.refreshRecipientShortcuts() if (!args.conversationScreenType.isInBubble) { @@ -2342,7 +2344,6 @@ class ConversationFragment : binding.conversationItemRecycler.layoutManager = layoutManager scrollListener = ScrollListener() binding.conversationItemRecycler.addOnScrollListener(scrollListener!!) - binding.conversationItemRecycler.addOnItemTouchListener(pendingReactionOverlayTouchGuard) adapter = ConversationAdapterV2( lifecycleOwner = viewLifecycleOwner, @@ -4165,13 +4166,8 @@ class ConversationFragment : // 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() - // 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. - pendingReactionOverlayJob?.cancel() - pendingReactionOverlayJob = viewLifecycleOwner.lifecycleScope.launch { - container.hideAllAndAwaitSettled(composeText, conversationContent) - showReactionOverlay(itemView, item, target, focusedView) - } + container.hideAll(composeText) + showReactionOverlay(item, target, focusedView) } } else if (item.conversationMessage.isActiveCollapsedHead) { viewModel.onExpandEvents(item.conversationMessage.messageRecord.id) @@ -4182,12 +4178,8 @@ 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. - */ + /** Snapshots [target] and hands it to the reaction overlay. */ private fun showReactionOverlay( - itemView: View, item: MultiselectPart, target: InteractiveConversationElement, focusedView: View? @@ -4198,22 +4190,21 @@ class ConversationFragment : 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 - } + // Held, not re-read: teardown has to work from a screen that is already going. + val recycler = binding.conversationItemRecycler + val shade = overlayBinding.reactionsShade multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage)) - binding.conversationItemRecycler.invalidateItemDecorations() - binding.reactionsShade.visibility = View.VISIBLE - binding.conversationItemRecycler.suppressLayout(true) + recycler.invalidateItemDecorations() + shade.visibility = View.VISIBLE + recycler.suppressLayout(true) val audioUri = messageRecord.getAudioUriForLongClick() if (audioUri != null) { getVoiceNoteMediaController().pausePlayback(audioUri) } - val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler) + val childAdapterPosition = target.getAdapterPosition(recycler) var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null var videoBitmap: Bitmap? = null if (childAdapterPosition != RecyclerView.NO_POSITION) { @@ -4225,22 +4216,32 @@ class ConversationFragment : } } - val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap) + val snapshot = ConversationItemSelection.snapshotView(target, recycler, messageRecord, videoBitmap) val bodyBubble = target.bubbleView + val snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics( + snapshotOffset = bodyBubble.x, + contextMenuPadding = bodyBubble.x + ) + + val origin = overlayOriginOf(target, recycler) val selectedConversationModel = SelectedConversationModel( bitmap = snapshot, - itemX = itemView.x, - itemY = itemView.y + binding.conversationItemRecycler.translationY, - bubbleY = bodyBubble.y, + bubbleX = origin.x + snapshotMetrics.snapshotOffset, + bubbleY = origin.y + bodyBubble.y, bubbleWidth = bodyBubble.width, + contextMenuX = origin.x + snapshotMetrics.contextMenuPadding, audioUri = audioUri, isOutgoing = messageRecord.isOutgoing, focusedView = focusedView, - snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics( - snapshotOffset = bodyBubble.x, - contextMenuPadding = bodyBubble.x - ) + returnPosition = SelectedConversationModel.ReturnPosition { + if (view == null || target.root.parent == null || target.conversationMessage.messageRecord.id != messageRecord.id) { + null + } else { + val current = overlayOriginOf(target, recycler) + PointF(current.x + snapshotMetrics.snapshotOffset, current.y + bodyBubble.y) + } + } ) bodyBubble.visibility = View.INVISIBLE @@ -4259,13 +4260,14 @@ class ConversationFragment : selectedConversationModel, object : OnHideListener { override fun startHide(focusedView: View?) { + // Ahead of the started check: a dismiss while stopped would leave the chat dimmed. + multiselectItemDecoration.hideShade(recycler) + ViewUtil.fadeOut(shade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) + 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) - val searchField = expandedSearchField() if (searchField != null && focusedView == searchField) { // The input panel is gone while search is open, so composeText cannot take the keyboard back. @@ -4278,30 +4280,30 @@ class ConversationFragment : 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() - } - + // Likewise: otherwise the list stays frozen and the message invisible. + recycler.suppressLayout(false) + multiselectItemDecoration.setFocusedItem(null) + recycler.invalidateItemDecorations() bodyBubble.visibility = View.VISIBLE target.reactionsView.visibility = View.VISIBLE + viewModel.setHideScrollButtonsForReactionOverlay(false) if (quotedIndicatorVisible && target.quotedIndicatorView != null) { ViewUtil.fadeIn(target.quotedIndicatorView!!, 150) } - viewModel.setHideScrollButtonsForReactionOverlay(false) + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return + } + + if (selectedConversationModel.audioUri != null) { + getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id) + } + + if (mp4Holder != null) { + mp4Holder.show() + mp4Holder.resume() + } } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index 852f2a8d8b..65f4a7b063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -11,8 +11,6 @@ import androidx.core.view.doOnNextLayout import androidx.fragment.app.Fragment import androidx.fragment.app.findFragment import androidx.lifecycle.Lifecycle -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume var View.visible: Boolean get() { @@ -50,22 +48,6 @@ inline fun View.doAfterNextLayout(crossinline action: () -> Unit) { } } -/** - * The suspending form of [doAfterNextLayout]. Resumes once the traversal that laid this view out has - * finished, so a caller both reads the size it was just handed and is free to touch the hierarchy. - */ -suspend fun View.awaitAfterNextLayout(): Unit = suspendCancellableCoroutine { continuation -> - val listener = object : View.OnLayoutChangeListener { - override fun onLayoutChange(view: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { - view.removeOnLayoutChangeListener(this) - view.post { continuation.resume(Unit) } - } - } - - addOnLayoutChangeListener(listener) - continuation.invokeOnCancellation { removeOnLayoutChangeListener(listener) } -} - fun TextView.setRelativeDrawables( @DrawableRes start: Int = 0, @DrawableRes top: Int = 0, diff --git a/app/src/main/res/layout/conversation_reaction_scrubber.xml b/app/src/main/res/layout/conversation_reaction_scrubber.xml index 5dbdc0accc..4e73cc0c7f 100644 --- a/app/src/main/res/layout/conversation_reaction_scrubber.xml +++ b/app/src/main/res/layout/conversation_reaction_scrubber.xml @@ -4,14 +4,10 @@ tools:viewBindingIgnore="true" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/conversation_reaction_scrubber" - android:layout_width="0dp" - android:layout_height="0dp" + android:layout_width="match_parent" + android:layout_height="match_parent" android:elevation="1000dp" android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline" - app:layout_constraintStart_toStartOf="@+id/parent_start_guideline" - app:layout_constraintTop_toTopOf="@+id/status_bar_guideline" tools:visibility="visible"> - - - - diff --git a/app/src/main/res/layout/v2_conversation_overlay.xml b/app/src/main/res/layout/v2_conversation_overlay.xml new file mode 100644 index 0000000000..17372fb78f --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_overlay.xml @@ -0,0 +1,23 @@ + + + + + + + +