From 53e62f2be093497759b384a38bfa0e60c8a5301a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 29 Jun 2023 13:18:33 -0300 Subject: [PATCH] Add new text-only conversation item. --- .../test/ConversationElementGenerator.kt | 6 +- .../test/InternalConversationTestFragment.kt | 3 +- .../conversation/ConversationItem.java | 45 ++- .../conversation/ConversationItemSelection.kt | 36 +- .../ConversationItemSwipeCallback.java | 49 ++- .../ConversationSwipeAnimationHelper.java | 27 +- .../conversation/v2/ConversationAdapterV2.kt | 86 ++--- .../conversation/v2/ConversationFragment.kt | 37 +- .../conversation/v2/ConversationRepository.kt | 9 + .../conversation/v2/ConversationViewModel.kt | 4 + .../items/InteractiveConversationElement.kt | 45 +++ .../v2/items/V2ConversationContext.kt | 27 ++ .../v2/items/V2ConversationItemLayout.kt | 54 +++ .../v2/items/V2ConversationItemShape.kt | 180 +++++++++ ...V2ConversationItemTextOnlyBindingBridge.kt | 87 +++++ .../v2/items/V2ConversationItemTheme.kt | 105 ++++++ .../v2/items/V2ConversationItemViewHolder.kt | 349 ++++++++++++++++++ .../v2/items/V2FooterPositionDelegate.kt | 124 +++++++ .../securesms/util/Projection.java | 3 + ...2_conversation_item_text_only_incoming.xml | 164 ++++++++ ...2_conversation_item_text_only_outgoing.xml | 156 ++++++++ 21 files changed, 1485 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationContext.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt create mode 100644 app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml create mode 100644 app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index 649af268ac..0e7a73d159 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -59,6 +59,10 @@ class ConversationElementGenerator { return MessageTypes.BASE_SENT_TYPE or MessageTypes.SECURE_MESSAGE_BIT } + private fun getSentFailedOutgoingType(): Long { + return MessageTypes.BASE_SENT_FAILED_TYPE or MessageTypes.SECURE_MESSAGE_BIT + } + private fun generateMessage(key: ConversationElementKey): MappingModel<*> { val messageId = key.requireMessageId() val now = getNow() @@ -82,7 +86,7 @@ class ConversationElementGenerator { 1, testMessage, SlideDeck(), - if (isIncoming) getIncomingType() else getSentOutgoingType(), + if (isIncoming) getIncomingType() else getSentFailedOutgoingType(), emptySet(), emptySet(), 0, diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index 6412361e74..df4f057714 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -64,7 +64,8 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra glideRequests = GlideApp.with(this), clickListener = ClickListener(), hasWallpaper = springboardViewModel.hasWallpaper.value, - colorizer = Colorizer() + colorizer = Colorizer(), + startExpirationTimeout = {} ) if (springboardViewModel.hasWallpaper.value) { 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 666f10580b..b9327e0ecd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -60,6 +60,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.core.text.util.LinkifyCompat; import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.RecyclerView; import com.annimon.stream.Stream; import com.google.android.exoplayer2.MediaItem; @@ -98,6 +99,7 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.ui.payment.PaymentMessageView; +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.MediaTable; import org.thoughtcrime.securesms.database.MessageTable; @@ -172,7 +174,8 @@ import kotlin.jvm.functions.Function1; public final class ConversationItem extends RelativeLayout implements BindableConversationItem, RecipientForeverObserver, - OpenableGift + OpenableGift, + InteractiveConversationElement { private static final String TAG = Log.tag(ConversationItem.class); @@ -2114,6 +2117,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return getSnapshotProjections(coordinateRoot, true, true); } + @Override public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia) { return getSnapshotProjections(coordinateRoot, clipOutMedia, true); } @@ -2262,6 +2266,45 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return AnimationSign.get(ViewUtil.isLtr(this), messageRecord.isOutgoing()); } + @Override + public @Nullable View getQuotedIndicatorView() { + return quotedIndicator; + } + + @Override + public @NonNull View getReplyView() { + return reply; + } + + @Override + public @Nullable View getContactPhotoHolderView() { + return contactPhotoHolder; + } + + @Override + public @Nullable View getBadgeImageView() { + return badgeImageView; + } + + @NonNull @Override public List getBubbleViews() { + return Collections.singletonList(bodyBubble); + } + + @Override + public int getAdapterPosition(@NonNull RecyclerView recyclerView) { + return recyclerView.getChildAdapterPosition(this); + } + + @Override + public @NonNull ViewGroup getRoot() { + return this; + } + + @Override + public @NonNull View getBubbleView() { + return bodyBubble; + } + 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 1b930278f7..f8e64458cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt @@ -10,54 +10,58 @@ import androidx.core.graphics.withTranslation import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.hasNoBubble object ConversationItemSelection { @JvmStatic fun snapshotView( - conversationItem: ConversationItem, + target: InteractiveConversationElement, list: RecyclerView, messageRecord: MessageRecord, videoBitmap: Bitmap? ): Bitmap { val isOutgoing = messageRecord.isOutgoing - val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context) + val hasNoBubble = messageRecord.hasNoBubble(list.context) return snapshotMessage( - conversationItem = conversationItem, + target = target, list = list, videoBitmap = videoBitmap, - drawConversationItem = !isOutgoing || hasNoBubble, + drawConversationItem = !SignalStore.internalValues().useConversationFragmentV2() && (!isOutgoing || hasNoBubble), hasReaction = messageRecord.reactions.isNotEmpty() ) } private fun snapshotMessage( - conversationItem: ConversationItem, + target: InteractiveConversationElement, list: RecyclerView, videoBitmap: Bitmap?, drawConversationItem: Boolean, hasReaction: Boolean ): Bitmap { - val bodyBubble = conversationItem.bodyBubble - val reactionsView = conversationItem.reactionsView + val bodyBubble = target.bubbleView + val reactionsView = target.reactionsView val originalScale = bodyBubble.scaleX bodyBubble.scaleX = 1.0f bodyBubble.scaleY = 1.0f - val projections = conversationItem.getSnapshotProjections(list, false) + val projections = target.getSnapshotProjections(list, false) val path = Path() - val xTranslation = -conversationItem.x - bodyBubble.x - val yTranslation = -conversationItem.y - bodyBubble.y + val xTranslation = -target.root.x - bodyBubble.x + val yTranslation = -target.root.y - bodyBubble.y - val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list) - var scaledVideoBitmap = videoBitmap - if (videoBitmap != null) { + val mp4Projection = (target as? GiphyMp4Playable)?.getGiphyMp4PlayableProjection(list) + + var scaledVideoBitmap: Bitmap? = null + if (videoBitmap != null && mp4Projection != null) { scaledVideoBitmap = Bitmap.createScaledBitmap( videoBitmap, (videoBitmap.width / originalScale).toInt(), @@ -78,7 +82,7 @@ object ConversationItemSelection { } } - conversationItem.destroyAllDrawingCaches() + target.root.destroyAllDrawingCaches() var bitmapHeight = bodyBubble.height if (hasReaction) { @@ -93,7 +97,7 @@ object ConversationItemSelection { withTranslation(x = xTranslation, y = yTranslation) { list.draw(this) - if (scaledVideoBitmap != null) { + if (scaledVideoBitmap != null && mp4Projection != null) { drawBitmap(scaledVideoBitmap, mp4Projection.x - xTranslation, mp4Projection.y - yTranslation, null) } } @@ -106,7 +110,7 @@ object ConversationItemSelection { reactionsView.draw(this) } }.also { - mp4Projection.release() + mp4Projection?.release() bodyBubble.scaleX = originalScale bodyBubble.scaleY = originalScale } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java index 842a5426f1..dd6af68118 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java @@ -8,12 +8,16 @@ import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement; import org.thoughtcrime.securesms.util.AccessibilityUtil; import org.thoughtcrime.securesms.util.ServiceUtil; +import java.util.Objects; + public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX; @@ -87,14 +91,14 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac boolean isCorrectSwipeDir = sameSign(dx, sign); if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { - ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign); + ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder), Math.abs(dx), sign); recyclerView.invalidate(); - handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx)); + handleSwipeFeedback(recyclerView.getContext(), requireInteractiveConversationElement(viewHolder), Math.abs(dx)); if (canTriggerSwipe) { setTouchListener(recyclerView, viewHolder, Math.abs(dx)); } } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) { - ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1); + ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder), 0, 1); recyclerView.invalidate(); } @@ -104,10 +108,10 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac } } - private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) { + private void handleSwipeFeedback(@NonNull Context context, @NonNull InteractiveConversationElement interactiveConversationElement, float dx) { if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) { - vibrate(item.getContext()); - ConversationSwipeAnimationHelper.trigger(item); + vibrate(context); + ConversationSwipeAnimationHelper.trigger(interactiveConversationElement); shouldTriggerSwipeFeedback = false; } } @@ -115,10 +119,9 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) { if (cannotSwipeViewHolder(viewHolder)) return; - ConversationItem item = ((ConversationItem) viewHolder.itemView); - ConversationMessage messageRecord = item.getConversationMessage(); + InteractiveConversationElement element = requireInteractiveConversationElement(viewHolder); - onSwipeListener.onSwipe(messageRecord); + onSwipeListener.onSwipe(element.getConversationMessage()); } @SuppressLint("ClickableViewAccessibility") @@ -160,19 +163,35 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac private void resetProgressIfAnimationsDisabled(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) { - ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, + ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder), 0f, getSignFromDirection(viewHolder.itemView)); recyclerView.invalidate(); } } - private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { - if (!(viewHolder.itemView instanceof ConversationItem)) return true; + private @NonNull InteractiveConversationElement requireInteractiveConversationElement(@NonNull RecyclerView.ViewHolder viewHolder) { + return Objects.requireNonNull(getInteractiveConversationElement(viewHolder)); + } - ConversationItem item = ((ConversationItem) viewHolder.itemView); - return !swipeAvailabilityProvider.isSwipeAvailable(item.getConversationMessage()) || - item.disallowSwipe(latestDownX, latestDownY); + private @Nullable InteractiveConversationElement getInteractiveConversationElement(@NonNull RecyclerView.ViewHolder viewHolder) { + if (viewHolder instanceof InteractiveConversationElement) { + return (InteractiveConversationElement) viewHolder; + } else if (viewHolder.itemView instanceof InteractiveConversationElement) { + return (InteractiveConversationElement) viewHolder.itemView; + } else { + return null; + } + } + + private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { + InteractiveConversationElement element = getInteractiveConversationElement(viewHolder); + if (element == null) { + return true; + } + + return !swipeAvailabilityProvider.isSwipeAvailable(element.getConversationMessage()) || + element.disallowSwipe(latestDownX, latestDownY); } private void updateLatestDownCoordinate(float x, float y) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java index 30c43a4e3e..1e01d4f3b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java @@ -8,8 +8,11 @@ import android.view.animation.Interpolator; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement; import org.thoughtcrime.securesms.util.Util; +import java.util.List; + final class ConversationSwipeAnimationHelper { static final float TRIGGER_DX = dpToPx(64); @@ -30,23 +33,25 @@ final class ConversationSwipeAnimationHelper { private ConversationSwipeAnimationHelper() { } - public static void update(@NonNull ConversationItem conversationItem, float dx, float sign) { + public static void update(@NonNull InteractiveConversationElement interactiveConversationElement, float dx, float sign) { float progress = dx / TRIGGER_DX; - updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign); - updateReactionsTransition(conversationItem.reactionsView, dx, sign); - updateQuotedIndicatorTransition(conversationItem.quotedIndicator, dx, progress, sign); - updateReplyIconTransition(conversationItem.reply, dx, progress, sign); - updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign); - updateContactPhotoHolderTransition(conversationItem.badgeImageView, progress, sign); + updateBodyBubbleTransition(interactiveConversationElement.getBubbleViews(), dx, sign); + updateReactionsTransition(interactiveConversationElement.getReactionsView(), dx, sign); + updateQuotedIndicatorTransition(interactiveConversationElement.getQuotedIndicatorView(), dx, progress, sign); + updateReplyIconTransition(interactiveConversationElement.getReplyView(), dx, progress, sign); + updateContactPhotoHolderTransition(interactiveConversationElement.getContactPhotoHolderView(), progress, sign); + updateContactPhotoHolderTransition(interactiveConversationElement.getBadgeImageView(), progress, sign); } - public static void trigger(@NonNull ConversationItem conversationItem) { - triggerReplyIcon(conversationItem.reply); + public static void trigger(@NonNull InteractiveConversationElement interactiveConversationElement) { + triggerReplyIcon(interactiveConversationElement.getReplyView()); } - private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float dx, float sign) { - bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + private static void updateBodyBubbleTransition(@NonNull List bubbleViews, float dx, float sign) { + for (View view : bubbleViews) { + view.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } } private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) { 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 53c7fb39e6..6fdb28c096 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 @@ -16,7 +16,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.toOptional import org.thoughtcrime.securesms.BindableConversationItem import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge import org.thoughtcrime.securesms.conversation.ConversationHeaderView import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode @@ -32,7 +32,12 @@ import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly import org.thoughtcrime.securesms.conversation.v2.data.ThreadHeader +import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationContext +import org.thoughtcrime.securesms.conversation.v2.items.V2TextOnlyViewHolder +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 @@ -52,10 +57,11 @@ import java.util.Optional class ConversationAdapterV2( private val lifecycleOwner: LifecycleOwner, private val glideRequests: GlideRequests, - private val clickListener: ConversationAdapter.ItemClickListener, + override val clickListener: ItemClickListener, private var hasWallpaper: Boolean, - private val colorizer: Colorizer -) : PagingMappingAdapter(), ConversationAdapterBridge { + private val colorizer: Colorizer, + private val startExpirationTimeout: (MessageRecord) -> Unit +) : PagingMappingAdapter(), ConversationAdapterBridge, V2ConversationContext { companion object { private val TAG = Log.tag(ConversationAdapterV2::class.java) @@ -83,8 +89,8 @@ class ConversationAdapterV2( } registerFactory(OutgoingTextOnly::class.java) { parent -> - val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_sent_text_only, parent, false) - OutgoingTextOnlyViewHolder(view) + val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_text_only_outgoing, parent, false) + V2TextOnlyViewHolder(V2ConversationItemTextOnlyOutgoingBinding.bind(view).bridge(), this) } registerFactory(OutgoingMedia::class.java) { parent -> @@ -93,8 +99,8 @@ class ConversationAdapterV2( } registerFactory(IncomingTextOnly::class.java) { parent -> - val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_received_text_only, parent, false) - IncomingTextOnlyViewHolder(view) + val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_text_only_incoming, parent, false) + V2TextOnlyViewHolder(V2ConversationItemTextOnlyIncomingBinding.bind(view).bridge(), this) } registerFactory(IncomingMedia::class.java) { parent -> @@ -123,6 +129,24 @@ class ConversationAdapterV2( } } } + override val displayMode: ConversationItemDisplayMode + get() = condensedMode ?: ConversationItemDisplayMode.STANDARD + + override fun onStartExpirationTimeout(messageRecord: MessageRecord) { + startExpirationTimeout(messageRecord) + } + + override fun hasWallpaper(): Boolean = hasWallpaper && displayMode.displayWallpaper() + + override fun getColorizer(): Colorizer = colorizer + + override fun getNextMessage(adapterPosition: Int): MessageRecord? { + return getConversationMessage(adapterPosition - 1)?.messageRecord + } + + override fun getPreviousMessage(adapterPosition: Int): MessageRecord? { + return getConversationMessage(adapterPosition + 1)?.messageRecord + } fun updateSearchQuery(searchQuery: String?) { this.searchQuery = searchQuery @@ -245,29 +269,6 @@ class ConversationAdapterV2( } } - private inner class OutgoingTextOnlyViewHolder(itemView: View) : ConversationViewHolder(itemView) { - override fun bind(model: OutgoingTextOnly) { - bindable.setEventListener(clickListener) - bindable.bind( - lifecycleOwner, - model.conversationMessage, - previousMessage, - nextMessage, - glideRequests, - Locale.getDefault(), - _selected, - model.conversationMessage.threadRecipient, - searchQuery, - false, - hasWallpaper && displayMode.displayWallpaper(), - true, // isMessageRequestAccepted, - model.conversationMessage == inlineContent, - colorizer, - displayMode - ) - } - } - private inner class OutgoingMediaViewHolder(itemView: View) : ConversationViewHolder(itemView) { override fun bind(model: OutgoingMedia) { bindable.setEventListener(clickListener) @@ -291,29 +292,6 @@ class ConversationAdapterV2( } } - private inner class IncomingTextOnlyViewHolder(itemView: View) : ConversationViewHolder(itemView) { - override fun bind(model: IncomingTextOnly) { - bindable.setEventListener(clickListener) - bindable.bind( - lifecycleOwner, - model.conversationMessage, - previousMessage, - nextMessage, - glideRequests, - Locale.getDefault(), - _selected, - model.conversationMessage.threadRecipient, - searchQuery, - false, - hasWallpaper && displayMode.displayWallpaper(), - true, // isMessageRequestAccepted, - model.conversationMessage == inlineContent, - colorizer, - displayMode - ) - } - } - private inner class IncomingMediaViewHolder(itemView: View) : ConversationViewHolder(itemView) { override fun bind(model: IncomingMedia) { bindable.setEventListener(clickListener) 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 2e4dad193d..ae153728aa 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 @@ -170,6 +170,7 @@ import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResults import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModelV2 import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel +import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord @@ -1117,7 +1118,8 @@ class ConversationFragment : glideRequests = GlideApp.with(this), clickListener = ConversationItemClickListener(), hasWallpaper = args.wallpaper != null, - colorizer = colorizer + colorizer = colorizer, + startExpirationTimeout = viewModel::startExpirationTimeout ) scrollToPositionDelegate = ScrollToPositionDelegate( @@ -2198,13 +2200,24 @@ class ConversationFragment : binding.reactionsShade.visibility = View.VISIBLE binding.conversationItemRecycler.suppressLayout(true) - if (itemView is ConversationItem) { + val target: InteractiveConversationElement? = if (itemView is InteractiveConversationElement) { + itemView + } else { + val viewHolder = binding.conversationItemRecycler.getChildViewHolder(itemView) + if (viewHolder is InteractiveConversationElement) { + viewHolder + } else { + null + } + } + + if (target != null) { val audioUri = messageRecord.getAudioUriForLongClick() if (audioUri != null) { getVoiceNoteMediaController().pausePlayback(audioUri) } - val childAdapterPosition = binding.conversationItemRecycler.getChildAdapterPosition(itemView) + val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler) var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null var videoBitmap: Bitmap? = null if (childAdapterPosition != RecyclerView.NO_POSITION) { @@ -2216,10 +2229,10 @@ class ConversationFragment : } } - val snapshot = ConversationItemSelection.snapshotView(itemView, binding.conversationItemRecycler, messageRecord, videoBitmap) + val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap) val focusedView = if (container.isInputShowing) null else itemView.rootView.findFocus() - val bodyBubble = itemView.bodyBubble!! + val bodyBubble = target.bubbleView val selectedConversationModel = SelectedConversationModel( snapshot, itemView.x, @@ -2233,11 +2246,11 @@ class ConversationFragment : ) bodyBubble.visibility = View.INVISIBLE - itemView.reactionsView?.visibility = View.INVISIBLE + target.reactionsView.visibility = View.INVISIBLE - val quotedIndicatorVisible = itemView.quotedIndicator?.visibility == View.VISIBLE + val quotedIndicatorVisible = target.quotedIndicatorView?.visibility == View.VISIBLE if (quotedIndicatorVisible) { - ViewUtil.fadeOut(itemView.quotedIndicator!!, 150, View.INVISIBLE) + ViewUtil.fadeOut(target.quotedIndicatorView!!, 150, View.INVISIBLE) } ViewUtil.hideKeyboard(requireContext(), itemView) @@ -2247,7 +2260,7 @@ class ConversationFragment : viewModel.setShowScrollButtons(false) } - val conversationItem: ConversationItem = itemView + val targetViews: InteractiveConversationElement = target handleReaction( item.conversationMessage, ReactionsToolbarListener(item.conversationMessage), @@ -2277,10 +2290,10 @@ class ConversationFragment : } bodyBubble.visibility = View.VISIBLE - conversationItem.reactionsView?.visibility = View.VISIBLE + targetViews.reactionsView.visibility = View.VISIBLE - if (quotedIndicatorVisible && conversationItem.quotedIndicator != null) { - ViewUtil.fadeIn(conversationItem.quotedIndicator!!, 150) + if (quotedIndicatorVisible && targetViews.quotedIndicatorView != null) { + ViewUtil.fadeIn(targetViews.quotedIndicatorView!!, 150) } if (showScrollButtons) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 9c22de7fc2..2f82a4936d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -560,6 +560,15 @@ class ConversationRepository( } } + fun startExpirationTimeout(messageRecord: MessageRecord) { + SignalExecutors.BOUNDED_IO.execute { + val now = System.currentTimeMillis() + + SignalDatabase.messages.markExpireStarted(messageRecord.id, now) + ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(messageRecord.id, messageRecord.isMms, now, messageRecord.expiresIn) + } + } + /** * Glide target for a contact photo which expects an error drawable, and publishes * the result to the given emitter. diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index c3505501aa..6cd16e35bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -266,6 +266,10 @@ class ConversationViewModel( } } + fun startExpirationTimeout(messageRecord: MessageRecord) { + repository.startExpirationTimeout(messageRecord) + } + fun updateReaction(messageRecord: MessageRecord, emoji: String): Completable { val oldRecord = messageRecord.oldReactionRecord() 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 new file mode 100644 index 0000000000..2c71ecf98b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/InteractiveConversationElement.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.util.ProjectionList + +/** + * A conversation element that a user can either swipe or snapshot + */ +interface InteractiveConversationElement { + val conversationMessage: ConversationMessage + + val root: ViewGroup + val bubbleView: View + val bubbleViews: List + val reactionsView: View + val quotedIndicatorView: View? + val replyView: View + val contactPhotoHolderView: View? + val badgeImageView: View? + + /** + * Whether or not the given element is swipeable + */ + fun disallowSwipe(latestDownX: Float, latestDownY: Float): Boolean + + /** + * Gets the adapter position for this element. Since this element can either be a ConversationItem or a + * ViewHolder, we require a delegate method. + */ + fun getAdapterPosition(recyclerView: RecyclerView): Int + + /** + * Note: Since we always clip out the view we want to display, we can ignore corners when providing this + * projection list. This will prevent artifacts when we draw the bitmap. + */ + fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationContext.kt new file mode 100644 index 0000000000..5aba61a441 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationContext.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.database.model.MessageRecord + +/** + * Describes the Adapter "context" that would normally have been + * visible to an inner class. + */ +interface V2ConversationContext { + val displayMode: ConversationItemDisplayMode + val clickListener: ConversationAdapter.ItemClickListener + + fun onStartExpirationTimeout(messageRecord: MessageRecord) + + fun hasWallpaper(): Boolean + fun getColorizer(): Colorizer + fun getNextMessage(adapterPosition: Int): MessageRecord? + fun getPreviousMessage(adapterPosition: Int): MessageRecord? +} 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 new file mode 100644 index 0000000000..dbe79585ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemLayout.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout + +/** + * Base Conversation item layout. Gives consistent patterns for manipulating child + * views. + */ +class V2ConversationItemLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + + private var onMeasureListener: OnMeasureListener? = null + + /** + * Set the onMeasureListener to be invoked by this view whenever onMeasure is called. + */ + fun setOnMeasureListener(onMeasureListener: OnMeasureListener?) { + this.onMeasureListener = onMeasureListener + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + onMeasureListener?.onPreMeasure() + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val remeasure = onMeasureListener?.onPostMeasure() ?: false + if (remeasure) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + interface OnMeasureListener { + /** + * Allows the view to be manipulated before super.onMeasure is called. + */ + fun onPreMeasure() + + /** + * Custom onMeasure implementation. Use this to position views and set padding + * *after* an initial measure pass, and optionally invoke an additional measure pass. + * + * @return true if super.onMeasure should be called again, false otherwise. + */ + fun onPostMeasure(): Boolean + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt new file mode 100644 index 0000000000..48eb43c683 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import org.signal.core.util.dp +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.isScheduled +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +/** + * Determines the shape for a conversation item based off of the appearance context + * and message data. + */ +class V2ConversationItemShape( + private val conversationContext: V2ConversationContext +) { + + companion object { + private var bigRadius: Float = 18f.dp + private var smallRadius: Float = 4f.dp + + private var collapsedSpacing: Float = 1f.dp + private var defaultSpacing: Float = 8f.dp + } + + var corners: Projection.Corners = Projection.Corners(bigRadius) + private set + + var bodyBubble: MaterialShapeDrawable = MaterialShapeDrawable( + ShapeAppearanceModel.Builder().setAllCornerSizes(bigRadius).build() + ) + private set + + var spacing: Pair = Pair(defaultSpacing, defaultSpacing) + private set + + /** + * Sets the message spacing and corners based off the given information. This + * updates the class state. + */ + fun setMessageShape( + isLtr: Boolean, + conversationMessage: ConversationMessage, + adapterPosition: Int + ): MessageShape { + val currentMessage: MessageRecord = conversationMessage.messageRecord + val nextMessage: MessageRecord? = conversationContext.getNextMessage(adapterPosition) + val previousMessage: MessageRecord? = conversationContext.getPreviousMessage(adapterPosition) + val isGroupThread: Boolean = conversationMessage.threadRecipient.isGroup + + if (isSingularMessage(currentMessage, previousMessage, nextMessage, isGroupThread)) { + setBodyBubbleCorners(isLtr, bigRadius, bigRadius, bigRadius, bigRadius) + spacing = Pair(defaultSpacing, defaultSpacing) + return MessageShape.SINGLE + } else if (isStartOfMessageCluster(currentMessage, previousMessage, isGroupThread)) { + val bottomEnd = if (currentMessage.isOutgoing) smallRadius else bigRadius + val bottomStart = if (currentMessage.isOutgoing) bigRadius else smallRadius + setBodyBubbleCorners(isLtr, bigRadius, bigRadius, bottomEnd, bottomStart) + spacing = Pair(defaultSpacing, collapsedSpacing) + return MessageShape.START + } else if (isEndOfMessageCluster(currentMessage, nextMessage)) { + val topStart = if (currentMessage.isOutgoing) bigRadius else smallRadius + val topEnd = if (currentMessage.isOutgoing) smallRadius else bigRadius + setBodyBubbleCorners(isLtr, topStart, topEnd, bigRadius, bigRadius) + spacing = Pair(collapsedSpacing, defaultSpacing) + return MessageShape.END + } else { + val start = if (currentMessage.isOutgoing) bigRadius else smallRadius + val end = if (currentMessage.isOutgoing) smallRadius else bigRadius + setBodyBubbleCorners(isLtr, start, end, end, start) + spacing = Pair(collapsedSpacing, collapsedSpacing) + return MessageShape.MIDDLE + } + } + + private fun setBodyBubbleCorners( + isLtr: Boolean, + topStart: Float, + topEnd: Float, + bottomEnd: Float, + bottomStart: Float + ) { + val newCorners = Projection.Corners( + if (isLtr) topStart else topEnd, + if (isLtr) topEnd else topStart, + if (isLtr) bottomEnd else bottomStart, + if (isLtr) bottomStart else bottomEnd + ) + if (corners == newCorners) { + return + } + + corners = newCorners + bodyBubble.shapeAppearanceModel = ShapeAppearanceModel.builder() + .setTopLeftCornerSize(corners.topLeft) + .setTopRightCornerSize(corners.topRight) + .setBottomLeftCornerSize(corners.bottomLeft) + .setBottomRightCornerSize(corners.bottomRight) + .build() + } + + private fun isSingularMessage( + currentMessage: MessageRecord, + previousMessage: MessageRecord?, + nextMessage: MessageRecord?, + isGroupThread: Boolean + ): Boolean { + return isStartOfMessageCluster(currentMessage, previousMessage, isGroupThread) && isEndOfMessageCluster(currentMessage, nextMessage) + } + + private fun isStartOfMessageCluster( + currentMessage: MessageRecord, + previousMessage: MessageRecord?, + isGroupThread: Boolean + ): Boolean { + if (previousMessage == null || + previousMessage.isUpdate || + !DateUtils.isSameDay(currentMessage.timestamp, previousMessage.timestamp) || + !isWithinClusteringTime(currentMessage, previousMessage) || + currentMessage.isScheduled() || + currentMessage.fromRecipient != previousMessage.fromRecipient + ) { + return true + } + + return isGroupThread || currentMessage.isSecure != previousMessage.isSecure + } + + private fun isEndOfMessageCluster( + currentMessage: MessageRecord, + nextMessage: MessageRecord? + ): Boolean { + if (nextMessage == null || + nextMessage.isUpdate || + !DateUtils.isSameDay(currentMessage.timestamp, nextMessage.timestamp) || + !isWithinClusteringTime(currentMessage, nextMessage) || + currentMessage.isScheduled() || + currentMessage.reactions.isNotEmpty() + ) { + return true + } + + return currentMessage.fromRecipient != nextMessage.fromRecipient + } + + private fun isWithinClusteringTime(currentMessage: MessageRecord, previousMessage: MessageRecord): Boolean { + return abs(currentMessage.dateSent - previousMessage.dateSent) <= TimeUnit.MINUTES.toMillis(3) + } + + enum class MessageShape { + /** + * This message stands alone. + */ + SINGLE, + + /** + * This message is the start of a cluster + */ + START, + + /** + * This message is the end of a cluster + */ + END, + + /** + * This message is in the middle of a cluster + */ + MIDDLE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt new file mode 100644 index 0000000000..ee3e88a489 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.google.android.material.imageview.ShapeableImageView +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.components.AlertView +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.DeliveryStatusView +import org.thoughtcrime.securesms.components.ExpirationTimerView +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding +import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding +import org.thoughtcrime.securesms.reactions.ReactionsConversationView + +/** + * Pass-through interface for bridging incoming and outgoing text-only message views. + * + * Essentially, just a convenience wrapper since the layouts differ *very slightly* and + * we want to be able to have each follow the same code-path. + */ +data class V2ConversationItemTextOnlyBindingBridge( + val root: V2ConversationItemLayout, + val senderName: EmojiTextView?, + val senderPhoto: AvatarImageView?, + val senderBadge: BadgeImageView?, + val conversationItemBodyWrapper: ViewGroup, + val conversationItemBody: EmojiTextView, + val conversationItemReply: ShapeableImageView, + val conversationItemReactions: ReactionsConversationView, + val conversationItemDeliveryStatus: DeliveryStatusView?, + val conversationItemFooterDate: TextView, + val conversationItemFooterExpiry: ExpirationTimerView, + val conversationItemFooterBackground: View, + val conversationItemAlert: AlertView?, + val isIncoming: Boolean +) + +/** + * Wraps the binding in the bridge. + */ +fun V2ConversationItemTextOnlyIncomingBinding.bridge(): V2ConversationItemTextOnlyBindingBridge { + return V2ConversationItemTextOnlyBindingBridge( + root = root, + senderName = groupMessageSender, + senderPhoto = contactPhoto, + senderBadge = badge, + conversationItemBody = conversationItemBody, + conversationItemBodyWrapper = conversationItemBodyWrapper, + conversationItemReply = conversationItemReply, + conversationItemReactions = conversationItemReactions, + conversationItemDeliveryStatus = null, + conversationItemFooterDate = conversationItemFooterDate, + conversationItemFooterExpiry = conversationItemExpirationTimer, + conversationItemFooterBackground = conversationItemFooterBackground, + conversationItemAlert = null, + isIncoming = false + ) +} + +/** + * Wraps the binding in the bridge. + */ +fun V2ConversationItemTextOnlyOutgoingBinding.bridge(): V2ConversationItemTextOnlyBindingBridge { + return V2ConversationItemTextOnlyBindingBridge( + root = root, + senderName = null, + senderPhoto = null, + senderBadge = null, + conversationItemBody = conversationItemBody, + conversationItemBodyWrapper = conversationItemBodyWrapper, + conversationItemReply = conversationItemReply, + conversationItemReactions = conversationItemReactions, + conversationItemDeliveryStatus = conversationItemDeliveryStatus, + conversationItemFooterDate = conversationItemFooterDate, + conversationItemFooterExpiry = conversationItemExpirationTimer, + conversationItemFooterBackground = conversationItemFooterBackground, + conversationItemAlert = conversationItemAlert, + isIncoming = false + ) +} 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 new file mode 100644 index 0000000000..27aeaed1b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTheme.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.util.hasNoBubble + +/** + * Color information for conversation items. + */ +class V2ConversationItemTheme( + private val context: Context, + private val conversationContext: V2ConversationContext +) { + + @ColorInt + fun getReplyIconBackgroundColor(): Int { + return if (conversationContext.hasWallpaper()) { + ContextCompat.getColor(context, R.color.signal_colorSurface1) + } else { + Color.TRANSPARENT + } + } + + @ColorInt + fun getFooterIconColor( + conversationMessage: ConversationMessage + ): Int { + return getColor( + conversationMessage, + conversationContext.getColorizer()::getOutgoingFooterIconColor, + conversationContext.getColorizer()::getIncomingFooterIconColor + ) + } + + @ColorInt + fun getFooterTextColor( + conversationMessage: ConversationMessage + ): Int { + return getColor( + conversationMessage, + conversationContext.getColorizer()::getOutgoingFooterTextColor, + conversationContext.getColorizer()::getIncomingFooterTextColor + ) + } + + @ColorInt + fun getBodyTextColor( + conversationMessage: ConversationMessage + ): Int { + return getColor( + conversationMessage, + conversationContext.getColorizer()::getOutgoingBodyTextColor, + conversationContext.getColorizer()::getIncomingBodyTextColor + ) + } + + fun getBodyBubbleColor( + conversationMessage: ConversationMessage + ): ColorStateList { + if (conversationMessage.messageRecord.hasNoBubble(context)) { + return ColorStateList.valueOf(Color.TRANSPARENT) + } + + return getFooterBubbleColor(conversationMessage) + } + + fun getFooterBubbleColor( + conversationMessage: ConversationMessage + ): ColorStateList { + return ColorStateList.valueOf( + if (conversationMessage.messageRecord.isOutgoing) { + Color.TRANSPARENT + } else { + if (conversationContext.hasWallpaper()) { + ContextCompat.getColor(context, R.color.signal_colorSurface) + } else { + ContextCompat.getColor(context, R.color.signal_colorSurface2) + } + } + ) + } + + @ColorInt + private fun getColor( + conversationMessage: ConversationMessage, + outgoingColor: (Context) -> Int, + incomingColor: (Context, Boolean) -> Int + ): Int { + return if (conversationMessage.messageRecord.isOutgoing) { + outgoingColor(context) + } else { + incomingColor(context, conversationContext.hasWallpaper()) + } + } +} 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 new file mode 100644 index 0000000000..d559e1f5e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +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.MultiselectPart +import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.ProjectionList +import org.thoughtcrime.securesms.util.SignalLocalMetrics +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.hasNoBubble +import org.thoughtcrime.securesms.util.isScheduled +import org.thoughtcrime.securesms.util.visible +import java.util.Locale + +/** + * Base ViewHolder to share some common properties shared among conversation items. + */ +abstract class V2BaseViewHolder>( + root: V2ConversationItemLayout, + appearanceInfoProvider: V2ConversationContext +) : MappingViewHolder(root) { + protected val shapeDelegate = V2ConversationItemShape(appearanceInfoProvider) + protected val themeDelegate = V2ConversationItemTheme(context, appearanceInfoProvider) +} + +/** + * Represents a text-only conversation item. + */ +class V2TextOnlyViewHolder>( + private val binding: V2ConversationItemTextOnlyBindingBridge, + private val conversationContext: V2ConversationContext +) : V2BaseViewHolder(binding.root, conversationContext), Colorizable, InteractiveConversationElement { + + private var messageId: Long = Long.MAX_VALUE + + private val projections = ProjectionList() + private val footerDelegate = V2FooterPositionDelegate(binding) + + private val conversationItemFooterBackgroundCorners = Projection.Corners(18f.dp) + private val conversationItemFooterBackground = MaterialShapeDrawable( + ShapeAppearanceModel.Builder() + .setAllCornerSizes(18f.dp) + .build() + ) + + override lateinit var conversationMessage: ConversationMessage + override val root: ViewGroup = binding.root + override val bubbleView: View = binding.conversationItemBodyWrapper + + override val bubbleViews: List = listOfNotNull( + binding.conversationItemBodyWrapper, + binding.conversationItemFooterDate, + binding.conversationItemFooterExpiry, + binding.conversationItemDeliveryStatus, + binding.conversationItemFooterBackground + ) + + override val reactionsView: View = binding.conversationItemReactions + override val quotedIndicatorView: View? = null + override val replyView: View = binding.conversationItemReply + override val contactPhotoHolderView: View? = binding.senderPhoto + override val badgeImageView: View? = binding.senderBadge + + init { + binding.root.setOnMeasureListener(footerDelegate) + } + + 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( + itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR, + conversationMessage, + bindingAdapterPosition + ) + + binding.conversationItemBody.setTextColor(themeDelegate.getBodyTextColor(conversationMessage)) + shapeDelegate.bodyBubble.fillColor = themeDelegate.getBodyBubbleColor(conversationMessage) + + binding.conversationItemBody.text = conversationMessage.getDisplayBody(context) + binding.conversationItemBodyWrapper.background = shapeDelegate.bodyBubble + binding.conversationItemReply.setBackgroundColor(themeDelegate.getReplyIconBackgroundColor()) + + presentDate(shape) + presentDeliveryStatus(shape) + presentFooterBackground(shape) + presentFooterExpiry(shape) + presentAlert() + presentSender() + + val (topPadding, bottomPadding) = shapeDelegate.spacing + ViewUtil.setPaddingTop(itemView, topPadding.toInt()) + ViewUtil.setPaddingBottom(itemView, bottomPadding.toInt()) + } + + override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition + + override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList { + projections.clear() + + projections.add( + Projection.relativeToParent( + coordinateRoot, + binding.conversationItemBodyWrapper, + Projection.Corners.NONE + ).translateX(binding.conversationItemBodyWrapper.translationX) + ) + + return projections + } + + override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList { + projections.clear() + + if (conversationMessage.messageRecord.isOutgoing) { + if (!conversationMessage.messageRecord.hasNoBubble(context)) { + projections.add( + Projection.relativeToParent( + coordinateRoot, + binding.conversationItemBodyWrapper, + shapeDelegate.corners + ).translateX(binding.conversationItemBodyWrapper.translationX) + ) + } else if (conversationContext.hasWallpaper()) { + projections.add( + Projection.relativeToParent( + coordinateRoot, + binding.conversationItemFooterBackground, + conversationItemFooterBackgroundCorners + ).translateX(binding.conversationItemFooterBackground.translationX) + ) + } + } + + return projections + } + + private fun MessageRecord.buildMessageId(): Long { + return if (isMms) -id else id + } + + private fun presentFooterExpiry(shape: V2ConversationItemShape.MessageShape) { + if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) { + binding.conversationItemFooterExpiry.stopAnimation() + binding.conversationItemFooterExpiry.visible = false + return + } + + binding.conversationItemFooterExpiry.setColorFilter(themeDelegate.getFooterIconColor(conversationMessage)) + + val timer = binding.conversationItemFooterExpiry + val record = conversationMessage.messageRecord + if (record.expiresIn > 0 && !record.isPending) { + binding.conversationItemFooterExpiry.visible = true + binding.conversationItemFooterExpiry.setPercentComplete(0f) + + if (record.expireStarted > 0) { + timer.setExpirationTime(record.expireStarted, record.expiresIn) + timer.startAnimation() + + if (record.expireStarted + record.expiresIn <= System.currentTimeMillis()) { + ApplicationDependencies.getExpiringMessageManager().checkSchedule() + } + } else if (!record.isOutgoing && !record.isMediaPending) { + conversationContext.onStartExpirationTimeout(record) + } + } else { + timer.visible = false + } + } + + private fun presentSender() { + if (binding.senderName == null || binding.senderPhoto == null || binding.senderBadge == null) { + return + } + + if (conversationMessage.threadRecipient.isGroup) { + val sender = conversationMessage.messageRecord.fromRecipient + binding.senderName.visible = true + binding.senderPhoto.visible = true + binding.senderBadge.visible = true + + binding.senderName.text = sender.getDisplayName(context) + binding.senderName.setTextColor(conversationContext.getColorizer().getIncomingGroupSenderColor(context, sender)) + binding.senderPhoto.setAvatar(sender) + binding.senderBadge.setBadgeFromRecipient(sender) + } else { + binding.senderName.visible = false + binding.senderPhoto.visible = false + binding.senderBadge.visible = false + } + } + + private fun presentAlert() { + val record = conversationMessage.messageRecord + binding.conversationItemBody.setCompoundDrawablesWithIntrinsicBounds( + 0, + 0, + if (record.isKeyExchange) R.drawable.ic_menu_login else 0, + 0 + ) + + val alert = binding.conversationItemAlert ?: return + + when { + record.isFailed -> alert.setFailed() + record.isPendingInsecureSmsFallback -> alert.setPendingApproval() + record.isRateLimited -> alert.setRateLimited() + else -> alert.setNone() + } + + if (conversationContext.hasWallpaper()) { + alert.setBackgroundResource(R.drawable.wallpaper_message_decoration_background) + } else { + alert.background = null + } + } + + private fun presentFooterBackground(shape: V2ConversationItemShape.MessageShape) { + if (!binding.conversationItemBody.isJumbomoji || + !conversationContext.hasWallpaper() || + shape == V2ConversationItemShape.MessageShape.MIDDLE || + shape == V2ConversationItemShape.MessageShape.START + ) { + binding.conversationItemFooterBackground.visible = false + return + } + + binding.conversationItemFooterBackground.visible = true + binding.conversationItemFooterBackground.background = conversationItemFooterBackground + conversationItemFooterBackground.fillColor = themeDelegate.getFooterBubbleColor(conversationMessage) + } + + private fun presentDate(shape: V2ConversationItemShape.MessageShape) { + if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) { + binding.conversationItemFooterDate.visible = false + return + } + + binding.conversationItemFooterDate.visible = true + binding.conversationItemFooterDate.setTextColor(themeDelegate.getFooterTextColor(conversationMessage)) + + val record = conversationMessage.messageRecord + if (record.isFailed) { + val errorMessage = when { + record.hasFailedWithNetworkFailures() -> R.string.ConversationItem_error_network_not_delivered + record.toRecipient.isPushGroup && record.isIdentityMismatchFailure -> R.string.ConversationItem_error_partially_not_delivered + else -> R.string.ConversationItem_error_not_sent_tap_for_details + } + + binding.conversationItemFooterDate.setText(errorMessage) + } else if (record.isPendingInsecureSmsFallback) { + binding.conversationItemFooterDate.setText(R.string.ConversationItem_click_to_approve_unencrypted) + } else if (record.isRateLimited) { + binding.conversationItemFooterDate.setText(R.string.ConversationItem_send_paused) + } else if (record.isScheduled()) { + binding.conversationItemFooterDate.text = (DateUtils.getOnlyTimeString(getContext(), Locale.getDefault(), (record as MediaMmsMessageRecord).scheduledDate)) + } else { + var date = DateUtils.getSimpleRelativeTimeSpanString(context, Locale.getDefault(), record.timestamp) + if (conversationContext.displayMode != ConversationItemDisplayMode.DETAILED && record is MediaMmsMessageRecord && record.isEditMessage()) { + date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date) + } + + binding.conversationItemFooterDate.text = date + } + } + + private fun presentDeliveryStatus(shape: V2ConversationItemShape.MessageShape) { + val deliveryStatus = binding.conversationItemDeliveryStatus ?: return + + if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) { + deliveryStatus.setNone() + return + } + + val record = conversationMessage.messageRecord + val newMessageId = record.buildMessageId() + + if (messageId != newMessageId && deliveryStatus.isPending && !record.isPending) { + if (record.toRecipient.isGroup) { + SignalLocalMetrics.GroupMessageSend.onUiUpdated(record.id) + } else { + SignalLocalMetrics.IndividualMessageSend.onUiUpdated(record.id) + } + } + + messageId = newMessageId + + if (!record.isOutgoing || record.isFailed || record.isPendingInsecureSmsFallback || record.isScheduled()) { + deliveryStatus.setNone() + return + } + + val onlyShowSendingStatus = when { + record.isOutgoing && !record.isRemoteDelete -> false + record.isRemoteDelete -> true + else -> false + } + + if (onlyShowSendingStatus) { + if (record.isPending) { + deliveryStatus.setPending() + } else { + deliveryStatus.setNone() + } + + return + } + + when { + record.isPending -> deliveryStatus.setPending() + record.isRemoteRead -> deliveryStatus.setRead() + record.isDelivered -> deliveryStatus.setDelivered() + else -> deliveryStatus.setSent() + } + } + + override fun disallowSwipe(latestDownX: Float, latestDownY: Float): Boolean { + return false + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt new file mode 100644 index 0000000000..fbfeaa01b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.view.View +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.util.padding +import org.thoughtcrime.securesms.util.visible + +/** + * Logical delegate for determining the footer position for a particular conversation item. + */ +class V2FooterPositionDelegate private constructor( + private val isIncoming: Boolean, + private val root: V2ConversationItemLayout, + private val footerViews: List, + private val bodyContainer: View, + private val body: EmojiTextView +) : V2ConversationItemLayout.OnMeasureListener { + + constructor(binding: V2ConversationItemTextOnlyBindingBridge) : this( + binding.isIncoming, + binding.root, + listOfNotNull(binding.conversationItemFooterDate, binding.conversationItemDeliveryStatus, binding.conversationItemFooterExpiry), + binding.conversationItemBodyWrapper, + binding.conversationItemBody + ) + + private val gutters = 48.dp + 16.dp + private val horizontalFooterPadding = root.context.resources.getDimensionPixelOffset(R.dimen.message_bubble_horizontal_padding) + + private var displayState: DisplayState = DisplayState.NONE + + override fun onPreMeasure() { + displayTuckedIntoBody() + } + + override fun onPostMeasure(): Boolean { + val maxWidth = root.measuredWidth - gutters + val lastLineWidth = body.lastLineWidth + val footerWidth = footerViews.sumOf { it.measuredWidth } + + if (footerViews.all { !it.visible }) { + return false + } + + if (body.isJumbomoji) { + displayUnderneathBody() + return true + } + + val availableTuck = bodyContainer.measuredWidth - lastLineWidth - (horizontalFooterPadding * 2) + if (body.lineCount > 1 && availableTuck > footerWidth) { + return false + } + + val availableWidth = maxWidth - lastLineWidth + if (body.lineCount == 1 && availableWidth > footerWidth) { + displayAtEndOfBody() + return true + } + + displayUnderneathBody() + return true + } + + private fun displayUnderneathBody() { + if (displayState == DisplayState.UNDERNEATH) { + return + } + + footerViews.forEach { + it.translationY = 0f + } + + bodyContainer.padding(right = 0, left = 0, bottom = footerViews.first().measuredHeight) + displayState = DisplayState.UNDERNEATH + } + + private fun displayAtEndOfBody() { + if (displayState == DisplayState.END) { + return + } + + footerViews.forEach { + it.translationY = 0f + } + + val end = footerViews.sumOf { it.measuredWidth } + if (isIncoming) 4.dp else 8.dp + val (left, right) = if (bodyContainer.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + 0 to end + } else { + end to 0 + } + + bodyContainer.padding(right = right, left = left, bottom = 0) + displayState = DisplayState.END + } + + private fun displayTuckedIntoBody() { + if (displayState == DisplayState.TUCKED) { + return + } + + footerViews.forEach { + it.translationY = 0f + } + + bodyContainer.padding(right = 0, left = 0, bottom = 0) + displayState = DisplayState.TUCKED + } + + private enum class DisplayState { + NONE, + UNDERNEATH, + END, + TUCKED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java index c07deee231..213de56389 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java @@ -248,6 +248,9 @@ public final class Projection { public static final class Corners { + + public static final Corners NONE = new Corners(0f); + private final float topLeft; private final float topRight; private final float bottomRight; 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 new file mode 100644 index 0000000000..35b0cfc9b1 --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_item_text_only_incoming.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000000..75fe3b1b88 --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file