From 38b2a2f5b769a4d449d8e426449981a8e97cd29a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 6 Jul 2023 13:27:42 -0300 Subject: [PATCH] Add multi-select support to CFV2. --- .../conversation/ConversationItem.java | 1 + .../conversation/ConversationUpdateItem.java | 5 ++ .../mutiselect/MultiselectItemDecoration.kt | 81 +++++++++++++------ .../mutiselect/Multiselectable.kt | 24 ++++++ .../conversation/v2/ConversationAdapterV2.kt | 23 ++++-- .../items/InteractiveConversationElement.kt | 2 + .../v2/items/V2ConversationItemViewHolder.kt | 69 +++++++++++++--- .../thoughtcrime/securesms/util/ViewUtil.java | 4 + 8 files changed, 171 insertions(+), 38 deletions(-) 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 b9327e0ecd..e405116abe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -2122,6 +2122,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return getSnapshotProjections(coordinateRoot, clipOutMedia, true); } + @Override public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia, boolean outgoingOnly) { colorizerProjections.clear(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 4bd808b8d7..17e6205fb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -254,6 +254,11 @@ public final class ConversationUpdateItem extends FrameLayout return background; } + @Override + public @NonNull ViewGroup getRoot() { + return this; + } + static final class RecipientObserverManager { private final Observer recipientObserver; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index 52e3fd1287..dc2bb28d79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -34,7 +34,7 @@ import org.signal.core.util.SetUtil import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge.PulseRequest -import org.thoughtcrime.securesms.conversation.ConversationItem +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.ThemeUtil import org.thoughtcrime.securesms.util.ViewUtil @@ -179,8 +179,8 @@ class MultiselectItemDecoration( else -> ultramarine30 } - parent.children.filterIsInstance(Multiselectable::class.java).forEach { child -> - updateChildOffsets(parent, child as View) + parent.getMultiselectableChildren().forEach { child -> + updateChildOffsets(parent, child.root) val parts: MultiselectCollection = child.conversationMessage.multiselectCollection @@ -206,7 +206,12 @@ class MultiselectItemDecoration( val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia()) if (shadeAll) { - rect.set(0, child.top, child.right, child.bottom) + rect.set( + 0, + child.root.top - ViewUtil.getTopMargin(child.root), + child.root.right, + child.root.bottom + ViewUtil.getBottomMargin(child.root) + ) } else { rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart)) } @@ -239,7 +244,7 @@ class MultiselectItemDecoration( private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapterBridge) { val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true - val multiselectChildren: Sequence = parent.children.filterIsInstance(Multiselectable::class.java) + val multiselectChildren: Sequence = parent.getMultiselectableChildren() val isDarkTheme = ThemeUtil.isDarkTheme(parent.context) @@ -344,10 +349,11 @@ class MultiselectItemDecoration( private fun updateChildOffsets(parent: RecyclerView, child: View) { val adapter = parent.adapter as ConversationAdapterBridge val isLtr = ViewUtil.isLtr(child) + val multiselectable: Multiselectable = resolveMultiselectable(parent, child) ?: return val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation() - if ((isAnimatingSelection || adapter.selectedItems.isNotEmpty()) && child is Multiselectable) { - val target = child.getHorizontalTranslationTarget() + if ((isAnimatingSelection || adapter.selectedItems.isNotEmpty())) { + val target = multiselectable.getHorizontalTranslationTarget() if (target != null) { val start = if (isLtr) { @@ -368,7 +374,7 @@ class MultiselectItemDecoration( -translation } } - } else if (child is Multiselectable) { + } else { child.translationX = 0f } } @@ -429,26 +435,28 @@ class MultiselectItemDecoration( return } - for (child in parent.children) { - if (child is ConversationItem) { - path.reset() - canvas.save() + for (child in parent.getInteractableChildren()) { + path.reset() + canvas.save() - val adapterPosition = parent.getChildAdapterPosition(child) - val request = pulseRequestAnimators.keys.firstOrNull { it.position == adapterPosition && it.isOutgoing == child.isOutgoing } ?: continue - val animator = pulseRequestAnimators[request] ?: continue - if (!animator.isRunning) { - continue - } + val adapterPosition = child.getAdapterPosition(parent) - child.getSnapshotProjections(parent, false, false).use { projectionList -> - projectionList.forEach { it.applyToPath(path) } - } + val request = pulseRequestAnimators.keys.firstOrNull { + it.position == adapterPosition && it.isOutgoing == child.conversationMessage.messageRecord.isOutgoing + } ?: continue - canvas.clipPath(path) - canvas.drawColor(animator.animatedValue) - canvas.restore() + val animator = pulseRequestAnimators[request] ?: continue + if (!animator.isRunning) { + continue } + + child.getSnapshotProjections(parent, false, false).use { projectionList -> + projectionList.forEach { it.applyToPath(path) } + } + + canvas.clipPath(path) + canvas.drawColor(animator.animatedValue) + canvas.restore() } } @@ -499,6 +507,7 @@ class MultiselectItemDecoration( animator?.end() multiselectPartAnimatorMap[multiselectPart] = newAnimator } + Difference.REMOVED -> { val newAnimator = ValueAnimator.ofFloat(animator?.animatedFraction ?: 1f, 0f).apply { duration = 150L @@ -560,6 +569,30 @@ class MultiselectItemDecoration( } } + private fun RecyclerView.getMultiselectableChildren(): Sequence { + return if (SignalStore.internalValues().useConversationFragmentV2()) { + children.map { getChildViewHolder(it) }.filterIsInstance() + } else { + children.filterIsInstance() + } + } + + private fun RecyclerView.getInteractableChildren(): Sequence { + return if (SignalStore.internalValues().useConversationFragmentV2()) { + children.map { getChildViewHolder(it) }.filterIsInstance() + } else { + children.filterIsInstance() + } + } + + private fun resolveMultiselectable(parent: RecyclerView, child: View): Multiselectable? { + return if (SignalStore.internalValues().useConversationFragmentV2()) { + parent.getChildViewHolder(child) as? Multiselectable + } else { + child as? Multiselectable + } + } + private class PulseAnimator(pulseColor: Int) { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt index 21512dfadb..49ca9faf00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt @@ -1,20 +1,44 @@ package org.thoughtcrime.securesms.conversation.mutiselect import android.view.View +import android.view.ViewGroup import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.colors.Colorizable import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable +/** + * Describes a ConversationElement that can be included in multiselect mode. + */ interface Multiselectable : Colorizable, GiphyMp4Playable { val conversationMessage: ConversationMessage + val root: ViewGroup + /** + * For a given multiselect part, return the 'top' boundary of its corresponding region. + * This is required even if there is only a single region for the given message. + */ fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int + /** + * For a given multiselect part, return the 'bottom' boundary of its corresponding region. + * This is required even if there is only a single region for the given message. + */ fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int + /** + * See [ConversationItem] for an implementation. This should return the part relative + * to the last "down" touch point, relative to the Y-Axis. + */ fun getMultiselectPartForLatestTouch(): MultiselectPart + /** + * Gets the start-most view that we should translate to make room for the multiselect circle. + * Only relevant for incoming messages. + */ fun getHorizontalTranslationTarget(): View? + /** + * Allows an item to denote itself as non-selectable, even though it implements this interface. + */ fun hasNonSelectableMedia(): Boolean } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index 6fdb28c096..a7e349368b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.colors.Colorizable import org.thoughtcrime.securesms.conversation.colors.Colorizer import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement import org.thoughtcrime.securesms.conversation.v2.data.ConversationUpdate @@ -38,7 +39,6 @@ import org.thoughtcrime.securesms.conversation.v2.items.bridge import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding -import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil import org.thoughtcrime.securesms.messagerequests.MessageRequestState @@ -315,10 +315,12 @@ class ConversationAdapterV2( } } - private abstract inner class ConversationViewHolder(itemView: View) : MappingViewHolder(itemView), GiphyMp4Playable, Colorizable { + private abstract inner class ConversationViewHolder(itemView: View) : MappingViewHolder(itemView), Multiselectable, Colorizable { val bindable: BindableConversationItem get() = itemView as BindableConversationItem + override val root: ViewGroup = bindable.root + protected val previousMessage: Optional get() = getConversationMessage(bindingAdapterPosition + 1)?.messageRecord.toOptional() @@ -328,6 +330,9 @@ class ConversationAdapterV2( protected val displayMode: ConversationItemDisplayMode get() = condensedMode ?: ConversationItemDisplayMode.STANDARD + override val conversationMessage: ConversationMessage + get() = bindable.conversationMessage + init { itemView.setOnClickListener { clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch()) @@ -370,9 +375,17 @@ class ConversationAdapterV2( return bindable.shouldProjectContent() } - override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList { - return bindable.getColorizerProjections(coordinateRoot) - } + override fun hasNonSelectableMedia(): Boolean = bindable.hasNonSelectableMedia() + + override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList = bindable.getColorizerProjections(coordinateRoot) + + override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int = bindable.getTopBoundaryOfMultiselectPart(multiselectPart) + + override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int = bindable.getBottomBoundaryOfMultiselectPart(multiselectPart) + + override fun getHorizontalTranslationTarget(): View? = bindable.getHorizontalTranslationTarget() + + override fun getMultiselectPartForLatestTouch(): MultiselectPart = bindable.getMultiselectPartForLatestTouch() } inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder(itemView) { 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 2c71ecf98b..739f95a009 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 @@ -42,4 +42,6 @@ interface InteractiveConversationElement { * projection list. This will prevent artifacts when we draw the bitmap. */ fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList + + fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList } 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 e172516b2a..4f43fbc255 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 @@ -16,9 +16,9 @@ import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode import org.thoughtcrime.securesms.conversation.ConversationMessage -import org.thoughtcrime.securesms.conversation.colors.Colorizable import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -51,7 +51,7 @@ abstract class V2BaseViewHolder>( class V2TextOnlyViewHolder>( private val binding: V2ConversationItemTextOnlyBindingBridge, private val conversationContext: V2ConversationContext -) : V2BaseViewHolder(binding.root, conversationContext), Colorizable, InteractiveConversationElement { +) : V2BaseViewHolder(binding.root, conversationContext), Multiselectable, InteractiveConversationElement { private var messageId: Long = Long.MAX_VALUE @@ -66,6 +66,7 @@ class V2TextOnlyViewHolder>( ) override lateinit var conversationMessage: ConversationMessage + override val root: ViewGroup = binding.root override val bubbleView: View = binding.conversationItemBodyWrapper @@ -96,19 +97,22 @@ class V2TextOnlyViewHolder>( conversationMessage.messageRecord.isMms ) } + + binding.root.setOnClickListener { + conversationContext.clickListener.onItemClick(getMultiselectPartForLatestTouch()) + } + + binding.root.setOnLongClickListener { + conversationContext.clickListener.onItemLongClick(binding.root, getMultiselectPartForLatestTouch()) + + true + } } override fun bind(model: Model) { check(model is ConversationMessageElement) conversationMessage = model.conversationMessage - itemView.setOnClickListener(null) - itemView.setOnLongClickListener { - conversationContext.clickListener.onItemLongClick(itemView, MultiselectPart.Message(conversationMessage)) - - true - } - val shape = shapeDelegate.setMessageShape( isLtr = itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR, currentMessage = conversationMessage.messageRecord, @@ -140,6 +144,10 @@ class V2TextOnlyViewHolder>( override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList { + return getSnapshotProjections(coordinateRoot, clipOutMedia, true) + } + + override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList { projections.clear() projections.add( @@ -179,6 +187,49 @@ class V2TextOnlyViewHolder>( return projections } + override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int { + return root.top + } + + override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int { + return root.bottom + } + + override fun getMultiselectPartForLatestTouch(): MultiselectPart { + return conversationMessage.multiselectCollection.asSingle().singlePart + } + + override fun getHorizontalTranslationTarget(): View? { + return if (conversationMessage.messageRecord.isOutgoing) { + null + } else if (conversationMessage.threadRecipient.isGroup) { + binding.senderPhoto + } else { + binding.conversationItemBodyWrapper + } + } + + override fun hasNonSelectableMedia(): Boolean = false + override fun showProjectionArea() = Unit + + override fun hideProjectionArea() = Unit + + override fun getGiphyMp4PlayableProjection(coordinateRoot: ViewGroup): Projection { + return Projection + .relativeToParent( + coordinateRoot, + binding.conversationItemBodyWrapper, + shapeDelegate.corners + ) + .translateY(root.translationY) + .translateX(binding.conversationItemBodyWrapper.translationX) + .translateX(root.translationX) + } + + override fun canPlayContent(): Boolean = false + + override fun shouldProjectContent(): Boolean = false + private fun MessageRecord.buildMessageId(): Long { return if (isMms) -id else id } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java index d8cfc99c57..362bfec59e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -261,6 +261,10 @@ public final class ViewUtil { return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin; } + public static int getBottomMargin(@NonNull View view) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).bottomMargin; + } + public static void setLeftMargin(@NonNull View view, int margin) { if (isLtr(view)) { ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;