From 3738997832253d35d68956e0050c07e99abb6a0e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 22 Aug 2023 15:18:17 -0300 Subject: [PATCH] Add proper click handling support to ConversationItem V2. --- .../conversation/ConversationItem.java | 4 + .../conversation/ConversationItemSelection.kt | 7 ++ .../conversation/v2/ConversationFragment.kt | 2 +- .../items/InteractiveConversationElement.kt | 7 ++ .../v2/items/V2ConversationItemLayout.kt | 31 ++++++-- .../v2/items/V2ConversationItemTheme.kt | 4 +- .../v2/items/V2ConversationItemViewHolder.kt | 26 ++++++- .../items/V2OnDispatchTouchEventListener.kt | 75 +++++++++++++++++++ .../v2/items/V2TextOnlySnapshotStrategy.kt | 52 +++++++++++++ ...2_conversation_item_text_only_incoming.xml | 2 + ...2_conversation_item_text_only_outgoing.xml | 4 +- 11 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2OnDispatchTouchEventListener.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index d05644e689..9485752d47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -2308,6 +2308,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo // Intentionally left blank. } + @Override public @Nullable SnapshotStrategy getSnapshotStrategy() { + return null; + } + private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt index 1c091fb9c5..5966bc5836 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt @@ -43,6 +43,13 @@ object ConversationItemSelection { drawConversationItem: Boolean, hasReaction: Boolean ): Bitmap { + val snapshotStrategy = target.getSnapshotStrategy() + if (snapshotStrategy != null) { + return createBitmap(target.root.width, target.root.height).applyCanvas { + snapshotStrategy.snapshot(this) + } + } + val bodyBubble = target.bubbleView val reactionsView = target.reactionsView 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 31effb5bb1..0cc56afb62 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 @@ -2871,7 +2871,7 @@ class ConversationFragment : snapshot, itemView.x, itemView.y + binding.conversationItemRecycler.translationY, - bodyBubble.x, + if (target.getSnapshotStrategy() != null) itemView.x else bodyBubble.x, bodyBubble.y, bodyBubble.width, audioUri, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt index c2ad606263..d25a864630 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.conversation.v2.items +import android.graphics.Canvas import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -44,4 +45,10 @@ interface InteractiveConversationElement : ChatColorsDrawable.ChatColorsDrawable fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList + + fun getSnapshotStrategy(): SnapshotStrategy? + + interface SnapshotStrategy { + fun snapshot(canvas: Canvas) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt index b7816edb1a..2061d80869 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt @@ -7,6 +7,8 @@ package org.thoughtcrime.securesms.conversation.v2.items import android.content.Context import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View import androidx.constraintlayout.widget.ConstraintLayout /** @@ -19,6 +21,25 @@ class V2ConversationItemLayout @JvmOverloads constructor( ) : ConstraintLayout(context, attrs) { private var onMeasureListeners: Set = emptySet() + var onDispatchTouchEventListener: OnDispatchTouchEventListener? = null + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + if (ev != null) { + onDispatchTouchEventListener?.onDispatchTouchEvent(this, ev) + } + + return super.dispatchTouchEvent(ev) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + onMeasureListeners.forEach { it.onPreMeasure() } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val remeasure = onMeasureListeners.map { it.onPostMeasure() }.any { it } + if (remeasure) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } /** * Set the onMeasureListener to be invoked by this view whenever onMeasure is called. @@ -31,14 +52,8 @@ class V2ConversationItemLayout @JvmOverloads constructor( this.onMeasureListeners -= onMeasureListener } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - onMeasureListeners.forEach { it.onPreMeasure() } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - val remeasure = onMeasureListeners.map { it.onPostMeasure() }.any { it } - if (remeasure) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } + interface OnDispatchTouchEventListener { + fun onDispatchTouchEvent(view: View, motionEvent: MotionEvent) } interface OnMeasureListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt index f17c078842..930830fc24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt @@ -82,9 +82,9 @@ class V2ConversationItemTheme( Color.TRANSPARENT } else { if (conversationContext.hasWallpaper()) { - ContextCompat.getColor(context, R.color.signal_colorSurface) + ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_wallpaper) } else { - ContextCompat.getColor(context, R.color.signal_colorSurface2) + ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_normal) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt index 680fb36596..80fb71671a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt @@ -88,6 +88,7 @@ class V2TextOnlyViewHolder>( private val projections = ProjectionList() private val footerDelegate = V2FooterPositionDelegate(binding) + private val dispatchTouchEventListener = V2OnDispatchTouchEventListener(conversationContext, binding) override lateinit var conversationMessage: ConversationMessage @@ -115,6 +116,7 @@ class V2TextOnlyViewHolder>( init { binding.root.addOnMeasureListener(footerDelegate) + binding.root.onDispatchTouchEventListener = dispatchTouchEventListener binding.conversationItemReactions.setOnClickListener { conversationContext.clickListener @@ -135,7 +137,10 @@ class V2TextOnlyViewHolder>( true } - binding.conversationItemBody.isClickable = false + val passthroughClickListener = PassthroughClickListener() + binding.conversationItemBody.setOnClickListener(passthroughClickListener) + binding.conversationItemBody.setOnLongClickListener(passthroughClickListener) + binding.conversationItemBody.isFocusable = false binding.conversationItemBody.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().messageFontSize.toFloat()) binding.conversationItemBody.movementMethod = LongClickMovementMethod.getInstance(context) @@ -220,6 +225,10 @@ class V2TextOnlyViewHolder>( return projections } + override fun getSnapshotStrategy(): InteractiveConversationElement.SnapshotStrategy { + return V2TextOnlySnapshotStrategy(binding) + } + /** * Note: This is not necessary for CFV2 Text-Only items because the background is rendered by * [ChatColorsDrawable] @@ -603,4 +612,19 @@ class V2TextOnlyViewHolder>( return binding.conversationItemReactions.setReactions(conversationMessage.messageRecord.reactions, binding.conversationItemBodyWrapper.width) } } + + private inner class PassthroughClickListener : View.OnClickListener, View.OnLongClickListener { + override fun onClick(v: View?) { + binding.root.performClick() + } + + override fun onLongClick(v: View?): Boolean { + if (binding.conversationItemBody.hasSelection()) { + return false + } + + binding.root.performLongClick() + return true + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2OnDispatchTouchEventListener.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2OnDispatchTouchEventListener.kt new file mode 100644 index 0000000000..c52e0128aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2OnDispatchTouchEventListener.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode + +/** + * Responsible for the shrink-and-return feel of conversation bubbles when the user + * touches them. + */ +class V2OnDispatchTouchEventListener( + private val conversationContext: V2ConversationContext, + private val binding: V2ConversationItemTextOnlyBindingBridge +) : V2ConversationItemLayout.OnDispatchTouchEventListener { + + companion object { + private const val LONG_PRESS_SCALE_FACTOR = 0.95f + private const val SHRINK_BUBBLE_DELAY_MILLIS = 100L + } + + private val viewsToPivot = listOfNotNull( + binding.conversationItemFooterBackground, + binding.conversationItemFooterDate, + binding.conversationItemFooterExpiry, + binding.conversationItemDeliveryStatus, + binding.conversationItemReactions + ) + + private val shrinkBubble = Runnable { + binding.conversationItemBodyWrapper.animate() + .scaleX(LONG_PRESS_SCALE_FACTOR) + .scaleY(LONG_PRESS_SCALE_FACTOR) + .setUpdateListener { + (binding.root.parent as? ViewGroup)?.invalidate() + } + + viewsToPivot.forEach { + it.animate() + .scaleX(LONG_PRESS_SCALE_FACTOR) + .scaleY(LONG_PRESS_SCALE_FACTOR) + } + } + + override fun onDispatchTouchEvent(view: View, motionEvent: MotionEvent) { + if (conversationContext.displayMode == ConversationItemDisplayMode.CONDENSED) { + return + } + + viewsToPivot.forEach { + val deltaX = it.x - binding.conversationItemBodyWrapper.x + val deltaY = it.y - binding.conversationItemBodyWrapper.y + + it.pivotX = -(deltaX / 2f) + it.pivotY = -(deltaY / 2f) + } + + when (motionEvent.action) { + MotionEvent.ACTION_DOWN -> view.handler.postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + view.handler.removeCallbacks(shrinkBubble) + (viewsToPivot + binding.conversationItemBodyWrapper).forEach { + it.animate() + .scaleX(1f) + .scaleY(1f) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt new file mode 100644 index 0000000000..0cb9a3b502 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.graphics.Canvas +import androidx.core.view.isVisible +import org.thoughtcrime.securesms.util.visible + +/** + * Responsible for drawing the conversation bubble when a user long-presses it and the reaction + * overlay appears. + */ +class V2TextOnlySnapshotStrategy( + private val binding: V2ConversationItemTextOnlyBindingBridge +) : InteractiveConversationElement.SnapshotStrategy { + + private val viewsToRestoreScale = listOfNotNull( + binding.conversationItemBodyWrapper, + binding.conversationItemFooterBackground, + binding.conversationItemFooterDate, + binding.conversationItemFooterExpiry, + binding.conversationItemDeliveryStatus, + binding.conversationItemReactions + ) + + private val viewsToHide = listOfNotNull( + binding.senderPhoto, + binding.senderBadge + ) + + override fun snapshot(canvas: Canvas) { + val originalScales = viewsToRestoreScale.associateWith { Pair(it.scaleX, it.scaleY) } + viewsToRestoreScale.forEach { + it.scaleX = 1f + it.scaleY = 1f + } + + val originalIsVisible = viewsToHide.associateWith { it.isVisible } + viewsToHide.forEach { it.visible = false } + + binding.root.draw(canvas) + + originalIsVisible.forEach { (view, isVisible) -> view.isVisible = isVisible } + originalScales.forEach { view, (scaleX, scaleY) -> + view.scaleX = scaleX + view.scaleY = scaleY + } + } +} diff --git a/app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml b/app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml index a51ea6df96..c9a1f29065 100644 --- a/app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml +++ b/app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml @@ -99,6 +99,8 @@ android:textColorLink="@color/conversation_item_sent_text_primary_color" android:textSize="16sp" app:emoji_maxLength="1000" + app:emoji_renderMentions="true" + app:emoji_renderSpoilers="true" app:measureLastLine="true" app:scaleEmojis="true" tools:text="Testy test test test" /> diff --git a/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml b/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml index 91a99df434..f3c7ed75d1 100644 --- a/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml +++ b/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml @@ -55,6 +55,8 @@ android:textColorLink="@color/conversation_item_sent_text_primary_color" android:textSize="16sp" app:emoji_maxLength="1000" + app:emoji_renderMentions="true" + app:emoji_renderSpoilers="true" app:measureLastLine="true" app:scaleEmojis="true" tools:text="Mango pickle lorem ipsum" /> @@ -149,8 +151,8 @@ android:id="@+id/conversation_item_reactions" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="-4dp" android:layout_marginStart="5dp" + android:layout_marginTop="-4dp" android:orientation="horizontal" app:layout_constraintStart_toStartOf="@id/conversation_item_body_wrapper" app:layout_constraintTop_toBottomOf="@id/conversation_item_body_wrapper"