From 75b81a0fd2d044c4e3ba55bea7d6c5cea20d9448 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 29 Aug 2023 13:52:53 -0300 Subject: [PATCH] Add the groundwork for the ConversationItemV2 Media item. --- .../app/internal/InternalSettingsFragment.kt | 8 + .../app/internal/InternalSettingsState.kt | 3 +- .../app/internal/InternalSettingsViewModel.kt | 8 +- .../conversation/v2/ConversationAdapterV2.kt | 35 +++- .../v2/items/ChatColorsDrawable.kt | 28 ++- .../V2ConversationItemMediaBindingBridge.kt | 78 +++++++ .../V2ConversationItemMediaViewHolder.kt | 198 ++++++++++++++++++ ... => V2ConversationItemSnapshotStrategy.kt} | 2 +- ...> V2ConversationItemTextOnlyViewHolder.kt} | 13 +- .../v2/items/V2FooterPositionDelegate.kt | 38 +++- .../securesms/keyvalue/InternalValues.java | 9 + .../v2_conversation_item_media_incoming.xml | 192 +++++++++++++++++ .../v2_conversation_item_media_outgoing.xml | 179 ++++++++++++++++ ...2_conversation_item_text_only_incoming.xml | 11 +- ...2_conversation_item_text_only_outgoing.xml | 11 +- .../v2_conversation_item_thumbnail_stub.xml | 13 ++ 16 files changed, 787 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaViewHolder.kt rename app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/{V2TextOnlySnapshotStrategy.kt => V2ConversationItemSnapshotStrategy.kt} (97%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/{V2TextOnlyViewHolder.kt => V2ConversationItemTextOnlyViewHolder.kt} (98%) create mode 100644 app/src/main/res/layout/v2_conversation_item_media_incoming.xml create mode 100644 app/src/main/res/layout/v2_conversation_item_media_outgoing.xml create mode 100644 app/src/main/res/layout/v2_conversation_item_thumbnail_stub.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 4c858c9949..765bac7dc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -623,6 +623,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter viewModel.setUseConversationItemV2(!state.useConversationItemV2) } ) + + switchPref( + title = DSLSettingsText.from("Use V2 ConversationItem for Media"), + isChecked = state.useConversationItemV2ForMedia, + onClick = { + viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia) + } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 97bce4f6c5..256dae0c09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -22,5 +22,6 @@ data class InternalSettingsState( val disableStorageService: Boolean, val canClearOnboardingState: Boolean, val pnpInitialized: Boolean, - val useConversationItemV2: Boolean + val useConversationItemV2: Boolean, + val useConversationItemV2ForMedia: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 1075aadf65..60fbb77850 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -109,6 +109,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + fun setUseConversationItemV2Media(enabled: Boolean) { + SignalStore.internalValues().setUseConversationItemV2Media(enabled) + refresh() + } + fun addSampleReleaseNote() { repository.addSampleReleaseNote() } @@ -136,7 +141,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito disableStorageService = SignalStore.internalValues().storageServiceDisabled(), canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(), pnpInitialized = SignalStore.misc().hasPniInitializedDevices(), - useConversationItemV2 = SignalStore.internalValues().useConversationItemV2() + useConversationItemV2 = SignalStore.internalValues().useConversationItemV2(), + useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media() ) fun onClearOnboardingState() { 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 58795cbfbb..05b0f5b011 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 @@ -36,9 +36,12 @@ 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.V2ConversationItemMediaViewHolder +import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemTextOnlyViewHolder import org.thoughtcrime.securesms.conversation.v2.items.bridge import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaIncomingBinding +import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaOutgoingBinding import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer @@ -93,25 +96,37 @@ class ConversationAdapterV2( ConversationUpdateViewHolder(view) } - registerFactory(OutgoingMedia::class.java) { parent -> - val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_sent_multimedia, parent, false) - OutgoingMediaViewHolder(view) - } + if (SignalStore.internalValues().useConversationItemV2Media()) { + registerFactory(OutgoingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_media_outgoing, parent, false) + V2ConversationItemMediaViewHolder(V2ConversationItemMediaOutgoingBinding.bind(view).bridge(), this) + } - registerFactory(IncomingMedia::class.java) { parent -> - val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_received_multimedia, parent, false) - IncomingMediaViewHolder(view) + registerFactory(IncomingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_media_incoming, parent, false) + V2ConversationItemMediaViewHolder(V2ConversationItemMediaIncomingBinding.bind(view).bridge(), this) + } + } else { + registerFactory(OutgoingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_sent_multimedia, parent, false) + OutgoingMediaViewHolder(view) + } + + registerFactory(IncomingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_received_multimedia, parent, false) + IncomingMediaViewHolder(view) + } } if (SignalStore.internalValues().useConversationItemV2()) { registerFactory(OutgoingTextOnly::class.java) { parent -> val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_text_only_outgoing, parent, false) - V2TextOnlyViewHolder(V2ConversationItemTextOnlyOutgoingBinding.bind(view).bridge(), this) + V2ConversationItemTextOnlyViewHolder(V2ConversationItemTextOnlyOutgoingBinding.bind(view).bridge(), this) } registerFactory(IncomingTextOnly::class.java) { parent -> val view = CachedInflater.from(parent.context).inflate(R.layout.v2_conversation_item_text_only_incoming, parent, false) - V2TextOnlyViewHolder(V2ConversationItemTextOnlyIncomingBinding.bind(view).bridge(), this) + V2ConversationItemTextOnlyViewHolder(V2ConversationItemTextOnlyIncomingBinding.bind(view).bridge(), this) } } else { registerFactory(OutgoingTextOnly::class.java) { parent -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/ChatColorsDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/ChatColorsDrawable.kt index dbdc24bda9..6c6cb2acad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/ChatColorsDrawable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/ChatColorsDrawable.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items import android.graphics.Canvas import android.graphics.ColorFilter +import android.graphics.Outline import android.graphics.Path import android.graphics.PixelFormat import android.graphics.PointF @@ -14,6 +15,7 @@ import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import android.view.ViewGroup +import androidx.core.graphics.toRectF import androidx.core.graphics.withClip import androidx.core.graphics.withTranslation import androidx.core.view.children @@ -31,6 +33,7 @@ class ChatColorsDrawable : Drawable() { companion object { private var maskDrawable: Drawable? = null + private var latestBounds: Rect? = null /** * Binds the ChatColorsDrawable static cache to the lifecycle of the given recycler-view @@ -47,6 +50,7 @@ class ChatColorsDrawable : Drawable() { } private fun applyBounds(bounds: Rect) { + latestBounds = bounds maskDrawable?.bounds = bounds } } @@ -65,7 +69,7 @@ class ChatColorsDrawable : Drawable() { private val rect = RectF() private var gradientColors: ChatColors? = null - private var corners: FloatArray = floatArrayOf() + private var corners: FloatArray = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f) private var fillColor: Int = 0 override fun draw(canvas: Canvas) { @@ -98,6 +102,21 @@ class ChatColorsDrawable : Drawable() { return PixelFormat.TRANSLUCENT } + /** + * Note: APIs had the wrong name for setPath here, so we have to use the deprecated method. + */ + @Suppress("DEPRECATION") + override fun getOutline(outline: Outline) { + val path = Path() + path.addRoundRect( + bounds.toRectF(), + corners, + Path.Direction.CW + ) + + outline.setConvexPath(path) + } + /** * Applies the given [Projection] as the clipping path for the canvas on subsequent draws. * Also applies the given [Projection]'s (x,y) (Top, Left) coordinates as the mask offset, @@ -134,15 +153,20 @@ class ChatColorsDrawable : Drawable() { chatColors: ChatColors, corners: Corners ) { - this.gradientColors = chatColors this.corners = corners.toRadii() if (chatColors.isGradient()) { if (maskDrawable == null) { maskDrawable = chatColors.chatBubbleMask + + val maskBounds = latestBounds + if (maskBounds != null) { + maskDrawable?.bounds = maskBounds + } } this.fillColor = 0 + this.gradientColors = chatColors } else { this.fillColor = chatColors.asSingleColor() this.gradientColors = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt new file mode 100644 index 0000000000..684d98b0f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.widget.ImageView +import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaIncomingBinding +import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaOutgoingBinding +import org.thoughtcrime.securesms.util.views.Stub + +/** + * Pass-through interface for bridging incoming and outgoing media 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 V2ConversationItemMediaBindingBridge( + val textBridge: V2ConversationItemTextOnlyBindingBridge, + val thumbnailStub: Stub +) + +/** + * Wraps the binding in the bridge. + */ +fun V2ConversationItemMediaIncomingBinding.bridge(): V2ConversationItemMediaBindingBridge { + val textBridge = 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, + conversationItemFooterSpace = null, + isIncoming = true + ) + + return V2ConversationItemMediaBindingBridge( + textBridge = textBridge, + thumbnailStub = Stub(conversationItemThumbnailStub) + ) +} + +/** + * Wraps the binding in the bridge. + */ +fun V2ConversationItemMediaOutgoingBinding.bridge(): V2ConversationItemMediaBindingBridge { + val textBridge = 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, + conversationItemFooterSpace = footerEndPad, + isIncoming = false + ) + + return V2ConversationItemMediaBindingBridge( + textBridge = textBridge, + thumbnailStub = Stub(conversationItemThumbnailStub) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaViewHolder.kt new file mode 100644 index 0000000000..cd81eb1d4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaViewHolder.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import androidx.core.view.updateLayoutParams +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.Transition +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.changeConstraints + +/** + * Represents a media-backed conversation item. + */ +class V2ConversationItemMediaViewHolder>( + private val binding: V2ConversationItemMediaBindingBridge, + private val conversationContext: V2ConversationContext +) : V2ConversationItemTextOnlyViewHolder( + binding.textBridge, + conversationContext, + V2FooterPositionDelegate(binding) +) { + + private var thumbnailSlide: Slide? = null + private var placeholderTarget: PlaceholderTarget? = null + private val thumbnailSize = intArrayOf(0, 0) + + init { + binding.textBridge.conversationItemBodyWrapper.clipToOutline = true + } + + override fun bind(model: Model) { + conversationMessage = (model as ConversationMessageElement).conversationMessage + presentThumbnail() + super.bind(model) + } + + private fun presentThumbnail() { + val slideDeck = requireMediaMessage().slideDeck + if (slideDeck.thumbnailSlides.isEmpty() || slideDeck.thumbnailSlides.size > 1) { + binding.thumbnailStub.visibility = View.GONE + thumbnailSize[0] = -1 + thumbnailSize[1] = -1 + + binding.textBridge.root.changeConstraints { + this.constrainMaxWidth(binding.textBridge.conversationItemBodyWrapper.id, 0) + } + return + } + + binding.thumbnailStub.visibility = View.VISIBLE + + val thumbnail = slideDeck.thumbnailSlides.first() + + // TODO [alex] -- Is this correct? + if (thumbnail == thumbnailSlide) { + return + } + + thumbnailSlide = thumbnail + + conversationContext.glideRequests.clear(binding.thumbnailStub.get()) + + if (placeholderTarget != null) { + conversationContext.glideRequests.clear(placeholderTarget) + } + // endif + + val thumbnailUri = thumbnail.uri + val thumbnailBlur = thumbnail.placeholderBlur + + val thumbnailAttachment = thumbnail.asAttachment() + val thumbnailWidth = thumbnailAttachment.width + val thumbnailHeight = thumbnailAttachment.height + + val maxWidth = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_width) + val maxHeight = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_height) + + setThumbnailSize( + thumbnailWidth, + thumbnailHeight, + maxWidth, + maxHeight + ) + + binding.thumbnailStub.get().updateLayoutParams { + width = thumbnailSize[0] + height = thumbnailSize[1] + } + + binding.textBridge.root.changeConstraints { + this.constrainMaxWidth(binding.textBridge.conversationItemBodyWrapper.id, thumbnailSize[0]) + } + + if (thumbnailBlur != null) { + val placeholderTarget = PlaceholderTarget(binding.thumbnailStub.get()) + conversationContext + .glideRequests + .load(thumbnailBlur) + .centerInside() + .dontAnimate() + .override(thumbnailSize[0], thumbnailSize[1]) + .into(placeholderTarget) + + this.placeholderTarget = placeholderTarget + } + + if (thumbnailUri != null) { + conversationContext + .glideRequests + .load(DecryptableStreamUriLoader.DecryptableUri(thumbnailUri)) + .centerInside() + .dontAnimate() + .override(thumbnailSize[0], thumbnailSize[1]) + .into(binding.thumbnailStub.get()) + } + } + + private fun setThumbnailSize( + thumbnailWidth: Int, + thumbnailHeight: Int, + maxWidth: Int, + maxHeight: Int + ) { + if (thumbnailWidth == 0 || thumbnailHeight == 0) { + thumbnailSize[0] = maxWidth + thumbnailSize[1] = maxHeight + return + } + + if (thumbnailWidth <= maxWidth && thumbnailHeight <= maxHeight) { + thumbnailSize[0] = thumbnailWidth + thumbnailSize[1] = thumbnailHeight + return + } + + if (thumbnailWidth > maxWidth) { + val thumbnailScale = 1 - ((thumbnailWidth - maxWidth) / thumbnailWidth.toFloat()) + + thumbnailSize[0] = (thumbnailWidth * thumbnailScale).toInt() + thumbnailSize[1] = (thumbnailHeight * thumbnailScale).toInt() + } + + if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) { + return + } + + if (thumbnailHeight > maxHeight) { + val thumbnailScale = 1 - ((thumbnailHeight - maxHeight) / thumbnailHeight.toFloat()) + + thumbnailSize[0] = (thumbnailWidth * thumbnailScale).toInt() + thumbnailSize[1] = (thumbnailHeight * thumbnailScale).toInt() + } + + if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) { + return + } + + setThumbnailSize( + thumbnailSize[0], + thumbnailSize[1], + maxWidth, + maxHeight + ) + } + + private fun isThumbnailMetricsSatisfied(maxWidth: Int, maxHeight: Int): Boolean { + return thumbnailSize[0] in 1..maxWidth && thumbnailSize[1] in 1..maxHeight + } + + private fun requireMediaMessage(): MediaMmsMessageRecord { + return conversationMessage.messageRecord as MediaMmsMessageRecord + } + + private inner class PlaceholderTarget(view: ImageView) : CustomViewTarget(view) { + override fun onLoadFailed(errorDrawable: Drawable?) { + view.background = errorDrawable + } + + override fun onResourceCleared(placeholder: Drawable?) { + view.background = placeholder + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + view.background = resource + } + } +} 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/V2ConversationItemSnapshotStrategy.kt similarity index 97% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemSnapshotStrategy.kt index 888a5b1ddf..d9a8f0fa7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlySnapshotStrategy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemSnapshotStrategy.kt @@ -13,7 +13,7 @@ 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( +class V2ConversationItemSnapshotStrategy( private val binding: V2ConversationItemTextOnlyBindingBridge ) : InteractiveConversationElement.SnapshotStrategy { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlyViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt similarity index 98% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlyViewHolder.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt index ff281406d4..84c5143727 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2TextOnlyViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt @@ -57,9 +57,10 @@ import java.util.Locale /** * Represents a text-only conversation item. */ -class V2TextOnlyViewHolder>( +open class V2ConversationItemTextOnlyViewHolder>( private val binding: V2ConversationItemTextOnlyBindingBridge, - private val conversationContext: V2ConversationContext + private val conversationContext: V2ConversationContext, + footerDelegate: V2FooterPositionDelegate = V2FooterPositionDelegate(binding) ) : V2ConversationItemViewHolder(binding.root, conversationContext), Multiselectable, InteractiveConversationElement { companion object { @@ -73,7 +74,6 @@ class V2TextOnlyViewHolder>( private var messageId: Long = Long.MAX_VALUE private val projections = ProjectionList() - private val footerDelegate = V2FooterPositionDelegate(binding) private val dispatchTouchEventListener = V2OnDispatchTouchEventListener(conversationContext, binding) override lateinit var conversationMessage: ConversationMessage @@ -212,7 +212,7 @@ class V2TextOnlyViewHolder>( } override fun getSnapshotStrategy(): InteractiveConversationElement.SnapshotStrategy { - return V2TextOnlySnapshotStrategy(binding) + return V2ConversationItemSnapshotStrategy(binding) } /** @@ -324,7 +324,10 @@ class V2TextOnlyViewHolder>( binding.conversationItemBody.maxLines = Integer.MAX_VALUE } - binding.conversationItemBody.text = StringUtil.trim(styledText) + val bodyText = StringUtil.trim(styledText) + + binding.conversationItemBody.visible = bodyText.isNotEmpty() + binding.conversationItemBody.text = bodyText } private fun linkifyMessageBody(messageBody: Spannable) { 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 index b971666edf..0182c79a3a 100644 --- 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 @@ -6,11 +6,13 @@ package org.thoughtcrime.securesms.conversation.v2.items import android.view.View +import android.widget.ImageView import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.padding +import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible import kotlin.math.max @@ -18,15 +20,14 @@ import kotlin.math.max * 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 + private val body: EmojiTextView, + private val thumbnailView: Stub? ) : V2ConversationItemLayout.OnMeasureListener { constructor(binding: V2ConversationItemTextOnlyBindingBridge) : this( - binding.isIncoming, binding.root, listOfNotNull( binding.conversationItemFooterDate, @@ -35,7 +36,21 @@ class V2FooterPositionDelegate private constructor( binding.conversationItemFooterSpace ), binding.conversationItemBodyWrapper, - binding.conversationItemBody + binding.conversationItemBody, + null + ) + + constructor(binding: V2ConversationItemMediaBindingBridge) : this( + binding.textBridge.root, + listOfNotNull( + binding.textBridge.conversationItemFooterDate, + binding.textBridge.conversationItemDeliveryStatus, + binding.textBridge.conversationItemFooterExpiry, + binding.textBridge.conversationItemFooterSpace + ), + binding.textBridge.conversationItemBodyWrapper, + binding.textBridge.conversationItemBody, + binding.thumbnailStub ) private val gutters = 48.dp + 16.dp @@ -48,7 +63,12 @@ class V2FooterPositionDelegate private constructor( } override fun onPostMeasure(): Boolean { - val maxWidth = root.measuredWidth - gutters + val maxWidth = if (thumbnailView?.isVisible == true) { + thumbnailView.get().layoutParams.width + } else { + root.measuredWidth - gutters + } + val lastLineWidth = body.lastLineWidth val footerWidth = getFooterWidth() @@ -81,7 +101,7 @@ class V2FooterPositionDelegate private constructor( return } - bodyContainer.padding(right = 0, left = 0, bottom = footerViews.first().measuredHeight) + body.padding(right = 0, left = 0, bottom = footerViews.first().measuredHeight) displayState = DisplayState.UNDERNEATH } @@ -90,7 +110,7 @@ class V2FooterPositionDelegate private constructor( return } - val targetWidth = body.measuredWidth + getFooterWidth() + val targetWidth = body.measuredWidth + 24.dp + getFooterWidth() val end = max(0, targetWidth - bodyContainer.measuredWidth) - 8.dp val (left, right) = if (bodyContainer.layoutDirection == View.LAYOUT_DIRECTION_LTR) { 0 to end @@ -98,7 +118,7 @@ class V2FooterPositionDelegate private constructor( end to 0 } - bodyContainer.padding(right = right, left = left, bottom = 0) + body.padding(right = right, left = left, bottom = 0) displayState = DisplayState.END } @@ -107,7 +127,7 @@ class V2FooterPositionDelegate private constructor( return } - bodyContainer.padding(right = 0, left = 0, bottom = 0) + body.padding(right = 0, left = 0, bottom = 0) displayState = DisplayState.TUCKED } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index 364eb81581..6cb3ba9bce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -30,6 +30,7 @@ public final class InternalValues extends SignalStoreValues { public static final String FORCE_WEBSOCKET_MODE = "internal.force_websocket_mode"; public static final String LAST_SCROLL_POSITION = "internal.last_scroll_position"; public static final String CONVERSATION_ITEM_V2 = "internal.conversation_item_v2"; + public static final String CONVERSATION_ITEM_V2_MEDIA = "internal.conversation_item_v2_media"; InternalValues(KeyValueStore store) { super(store); @@ -198,4 +199,12 @@ public final class InternalValues extends SignalStoreValues { public boolean useConversationItemV2() { return FeatureFlags.internalUser() && getBoolean(CONVERSATION_ITEM_V2, false); } + + public void setUseConversationItemV2Media(boolean useConversationFragmentV2Media) { + putBoolean(CONVERSATION_ITEM_V2_MEDIA, useConversationFragmentV2Media); + } + + public boolean useConversationItemV2Media() { + return FeatureFlags.internalUser() && getBoolean(CONVERSATION_ITEM_V2_MEDIA, false); + } } diff --git a/app/src/main/res/layout/v2_conversation_item_media_incoming.xml b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml new file mode 100644 index 0000000000..5bf19ca418 --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml b/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml new file mode 100644 index 0000000000..43b409e08c --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 5d93e04d41..8dc25b6d02 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 @@ -55,7 +55,7 @@ @@ -90,10 +91,10 @@ android:id="@+id/conversation_item_body" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingStart="@dimen/message_bubble_horizontal_padding" - android:paddingTop="@dimen/message_bubble_top_padding" - android:paddingEnd="@dimen/message_bubble_horizontal_padding" - android:paddingBottom="@dimen/message_bubble_collapsed_footer_padding" + android:layout_marginStart="@dimen/message_bubble_horizontal_padding" + android:layout_marginTop="@dimen/message_bubble_top_padding" + android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" + android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:textAppearance="@style/Signal.Text.BodyLarge" android:textColor="@color/conversation_item_sent_text_primary_color" android:textColorLink="@color/conversation_item_sent_text_primary_color" 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 f3c7ed75d1..d04ed6a19e 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 @@ -30,7 +30,7 @@ @@ -46,10 +47,10 @@ android:id="@+id/conversation_item_body" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingStart="@dimen/message_bubble_horizontal_padding" - android:paddingTop="@dimen/message_bubble_top_padding" - android:paddingEnd="@dimen/message_bubble_horizontal_padding" - android:paddingBottom="@dimen/message_bubble_collapsed_footer_padding" + android:layout_marginStart="@dimen/message_bubble_horizontal_padding" + android:layout_marginTop="@dimen/message_bubble_top_padding" + android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" + android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:textAppearance="@style/Signal.Text.BodyLarge" android:textColor="@color/conversation_item_sent_text_primary_color" android:textColorLink="@color/conversation_item_sent_text_primary_color" diff --git a/app/src/main/res/layout/v2_conversation_item_thumbnail_stub.xml b/app/src/main/res/layout/v2_conversation_item_thumbnail_stub.xml new file mode 100644 index 0000000000..de4471e28a --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_item_thumbnail_stub.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file