Extract V2TextOnlyViewHolder to its own file.

This commit is contained in:
Alex Hart
2023-08-22 16:49:34 -03:00
committed by Cody Henthorne
parent 630d9492cd
commit c4109a19d6
2 changed files with 616 additions and 611 deletions

View File

@@ -5,626 +5,16 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.graphics.Color
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.BackgroundColorSpan
import android.text.style.CharacterStyle
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.StringUtil
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.BodyBubbleLayoutTransition
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LongClickMovementMethod
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ProjectionList
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.VibrateUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.hasExtraText
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<Model : MappingModel<Model>>(
abstract class V2ConversationItemViewHolder<Model : MappingModel<Model>>(
root: V2ConversationItemLayout,
appearanceInfoProvider: V2ConversationContext
) : MappingViewHolder<Model>(root) {
protected val shapeDelegate = V2ConversationItemShape(appearanceInfoProvider)
protected val themeDelegate = V2ConversationItemTheme(context, appearanceInfoProvider)
}
/**
* Represents a text-only conversation item.
*/
class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
private val binding: V2ConversationItemTextOnlyBindingBridge,
private val conversationContext: V2ConversationContext
) : V2BaseViewHolder<Model>(binding.root, conversationContext), Multiselectable, InteractiveConversationElement {
companion object {
private val STYLE_FACTORY = StyleFactory { arrayOf<CharacterStyle>(BackgroundColorSpan(Color.YELLOW), ForegroundColorSpan(Color.BLACK)) }
private const val CONDENSED_MODE_MAX_LINES = 3
private val footerCorners = Projection.Corners(18f.dp)
private val transparentChatColors = ChatColors.forColor(ChatColors.Id.NotSet, Color.TRANSPARENT)
}
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
override val root: ViewGroup = binding.root
override val bubbleView: View = binding.conversationItemBodyWrapper
override val bubbleViews: List<View> = 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
private var reactionMeasureListener: ReactionMeasureListener = ReactionMeasureListener()
private val bodyBubbleDrawable = ChatColorsDrawable()
private val footerDrawable = ChatColorsDrawable()
init {
binding.root.addOnMeasureListener(footerDelegate)
binding.root.onDispatchTouchEventListener = dispatchTouchEventListener
binding.conversationItemReactions.setOnClickListener {
conversationContext.clickListener
.onReactionClicked(
Multiselect.getParts(conversationMessage).asSingle().singlePart,
conversationMessage.messageRecord.id,
conversationMessage.messageRecord.isMms
)
}
binding.root.setOnClickListener {
conversationContext.clickListener.onItemClick(getMultiselectPartForLatestTouch())
}
binding.root.setOnLongClickListener {
conversationContext.clickListener.onItemLongClick(binding.root, getMultiselectPartForLatestTouch())
true
}
val passthroughClickListener = PassthroughClickListener()
binding.conversationItemBody.setOnClickListener(passthroughClickListener)
binding.conversationItemBody.setOnLongClickListener(passthroughClickListener)
binding.conversationItemBody.isFocusable = false
binding.conversationItemBody.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().messageFontSize.toFloat())
binding.conversationItemBody.movementMethod = LongClickMovementMethod.getInstance(context)
if (binding.isIncoming) {
binding.conversationItemBody.setMentionBackgroundTint(ContextCompat.getColor(context, if (ThemeUtil.isDarkTheme(context)) R.color.core_grey_60 else R.color.core_grey_20))
} else {
binding.conversationItemBody.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25))
}
binding.conversationItemBodyWrapper.background = bodyBubbleDrawable
binding.conversationItemBodyWrapper.layoutTransition = BodyBubbleLayoutTransition()
binding.conversationItemFooterBackground.background = footerDrawable
}
override fun invalidateChatColorsDrawable(coordinateRoot: ViewGroup) {
invalidateBodyBubbleDrawable(coordinateRoot)
invalidateFooterDrawable(coordinateRoot)
}
override fun bind(model: Model) {
check(model is ConversationMessageElement)
conversationMessage = model.conversationMessage
val shape = shapeDelegate.setMessageShape(
isLtr = itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR,
currentMessage = conversationMessage.messageRecord,
isGroupThread = conversationMessage.threadRecipient.isGroup,
adapterPosition = bindingAdapterPosition
)
presentBody()
presentDate(shape)
presentDeliveryStatus(shape)
presentFooterBackground(shape)
presentFooterExpiry(shape)
presentAlert()
presentSender()
presentReactions()
bodyBubbleDrawable.setChatColors(
if (binding.conversationItemBody.isJumbomoji) {
transparentChatColors
} else if (binding.isIncoming) {
ChatColors.forColor(ChatColors.Id.NotSet, themeDelegate.getBodyBubbleColor(conversationMessage))
} else {
conversationMessage.threadRecipient.chatColors
},
shapeDelegate.corners
)
binding.conversationItemReply.setBackgroundColor(themeDelegate.getReplyIconBackgroundColor())
itemView.updateLayoutParams<MarginLayoutParams> {
topMargin = shape.topPadding.toInt()
bottomMargin = shape.bottomPadding.toInt()
}
}
override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList {
return getSnapshotProjections(coordinateRoot, clipOutMedia, true)
}
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList {
projections.clear()
if (outgoingOnly && binding.isIncoming) {
return projections
}
projections.add(
Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
Projection.Corners.NONE
).translateX(binding.conversationItemBodyWrapper.translationX).translateY(root.translationY)
)
return projections
}
override fun getSnapshotStrategy(): InteractiveConversationElement.SnapshotStrategy {
return V2TextOnlySnapshotStrategy(binding)
}
/**
* Note: This is not necessary for CFV2 Text-Only items because the background is rendered by
* [ChatColorsDrawable]
*/
override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList {
projections.clear()
return projections
}
override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.top
}
override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.bottom
}
override fun getMultiselectPartForLatestTouch(): MultiselectPart {
return conversationMessage.multiselectCollection.asSingle().singlePart
}
override fun getHorizontalTranslationTarget(): View? {
return if (conversationMessage.messageRecord.isOutgoing) {
null
} else if (conversationMessage.threadRecipient.isGroup) {
binding.senderPhoto
} else {
binding.conversationItemBodyWrapper
}
}
override fun hasNonSelectableMedia(): Boolean = false
override fun showProjectionArea() = Unit
override fun hideProjectionArea() = Unit
override fun getGiphyMp4PlayableProjection(coordinateRoot: ViewGroup): Projection {
return Projection
.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
)
.translateY(root.translationY)
.translateX(binding.conversationItemBodyWrapper.translationX)
.translateX(root.translationX)
}
override fun canPlayContent(): Boolean = false
override fun shouldProjectContent(): Boolean = false
private fun invalidateFooterDrawable(coordinateRoot: ViewGroup) {
if (footerDrawable.isSolidColor()) {
return
}
val projection = Projection.relativeToParent(
coordinateRoot,
binding.conversationItemFooterBackground,
shapeDelegate.corners
)
footerDrawable.applyMaskProjection(projection)
projection.release()
}
private fun invalidateBodyBubbleDrawable(coordinateRoot: ViewGroup) {
if (bodyBubbleDrawable.isSolidColor()) {
return
}
val projection = Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
)
bodyBubbleDrawable.applyMaskProjection(projection)
projection.release()
}
private fun MessageRecord.buildMessageId(): Long {
return if (isMms) -id else id
}
private fun presentBody() {
binding.conversationItemBody.setTextColor(themeDelegate.getBodyTextColor(conversationMessage))
binding.conversationItemBody.setLinkTextColor(themeDelegate.getBodyTextColor(conversationMessage))
val record = conversationMessage.messageRecord
var styledText: Spannable = conversationMessage.getDisplayBody(context)
if (conversationContext.isMessageRequestAccepted) {
linkifyMessageBody(styledText)
}
styledText = SearchUtil.getHighlightedSpan(Locale.getDefault(), STYLE_FACTORY, styledText, conversationContext.searchQuery, SearchUtil.STRICT)
if (record.hasExtraText()) {
binding.conversationItemBody.setOverflowText(getLongMessageSpan())
} else {
binding.conversationItemBody.setOverflowText(null)
}
if (isContentCondensed()) {
binding.conversationItemBody.maxLines = CONDENSED_MODE_MAX_LINES
} else {
binding.conversationItemBody.maxLines = Integer.MAX_VALUE
}
binding.conversationItemBody.text = StringUtil.trim(styledText)
}
private fun linkifyMessageBody(messageBody: Spannable) {
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
if (conversationMessage.hasStyleLinks()) {
messageBody.getSpans(0, messageBody.length, PlaceholderURLSpan::class.java).forEach { placeholder ->
val start = messageBody.getSpanStart(placeholder)
val end = messageBody.getSpanEnd(placeholder)
val span: URLSpan = InterceptableLongClickCopyLinkSpan(
placeholder.value,
conversationContext.clickListener::onUrlClicked,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false
)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MentionAnnotation.getMentionAnnotations(messageBody).forEach { annotation ->
messageBody.setSpan(
MentionClickableSpan(RecipientId.from(annotation.value)),
messageBody.getSpanStart(annotation),
messageBody.getSpanEnd(annotation),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
private fun getLongMessageSpan(): CharSequence {
val message = context.getString(R.string.ConversationItem_read_more)
val span = SpannableStringBuilder(message)
span.setSpan(ReadMoreSpan(), 0, span.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return span
}
private inner class MentionClickableSpan(
private val recipientId: RecipientId
) : ClickableSpan() {
override fun onClick(widget: View) {
VibrateUtil.vibrateTick(context)
conversationContext.clickListener.onGroupMemberClicked(recipientId, conversationMessage.threadRecipient.requireGroupId())
}
override fun updateDrawState(ds: TextPaint) = Unit
}
private inner class ReadMoreSpan : ClickableSpan() {
override fun onClick(widget: View) {
if (conversationContext.selectedItems.isEmpty()) {
conversationContext.clickListener.onMoreTextClicked(
conversationMessage.threadRecipient.id,
conversationMessage.messageRecord.id,
conversationMessage.messageRecord.isMms
)
}
}
override fun updateDrawState(ds: TextPaint) {
ds.typeface = Typeface.DEFAULT_BOLD
}
}
private fun isContentCondensed(): Boolean {
return conversationContext.displayMode == ConversationItemDisplayMode.CONDENSED && conversationContext.getPreviousMessage(bindingAdapterPosition) == null
}
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(conversationContext.glideRequests, sender, false)
binding.senderBadge.setBadgeFromRecipient(sender, conversationContext.glideRequests)
} 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 presentReactions() {
if (conversationMessage.messageRecord.reactions.isEmpty()) {
binding.conversationItemReactions.clear()
binding.root.removeOnMeasureListener(reactionMeasureListener)
} else {
reactionMeasureListener.onPostMeasure()
binding.root.addOnMeasureListener(reactionMeasureListener)
}
}
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
footerDrawable.setChatColors(
if (binding.isIncoming) {
ChatColors.forColor(ChatColors.Id.NotSet, themeDelegate.getFooterBubbleColor(conversationMessage))
} else {
conversationMessage.threadRecipient.chatColors
},
footerCorners
)
}
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 = conversationMessage.formattedDate
} else {
var date = conversationMessage.formattedDate
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
}
private inner class ReactionMeasureListener : V2ConversationItemLayout.OnMeasureListener {
override fun onPreMeasure() = Unit
override fun onPostMeasure(): Boolean {
return binding.conversationItemReactions.setReactions(conversationMessage.messageRecord.reactions, binding.conversationItemBodyWrapper.width)
}
}
private inner class PassthroughClickListener : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View?) {
binding.root.performClick()
}
override fun onLongClick(v: View?): Boolean {
if (binding.conversationItemBody.hasSelection()) {
return false
}
binding.root.performLongClick()
return true
}
}
}

View File

@@ -0,0 +1,615 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.graphics.Color
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.BackgroundColorSpan
import android.text.style.CharacterStyle
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.StringUtil
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.BodyBubbleLayoutTransition
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LongClickMovementMethod
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ProjectionList
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.VibrateUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.hasExtraText
import org.thoughtcrime.securesms.util.isScheduled
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Represents a text-only conversation item.
*/
class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
private val binding: V2ConversationItemTextOnlyBindingBridge,
private val conversationContext: V2ConversationContext
) : V2ConversationItemViewHolder<Model>(binding.root, conversationContext), Multiselectable, InteractiveConversationElement {
companion object {
private val STYLE_FACTORY = SearchUtil.StyleFactory { arrayOf<CharacterStyle>(BackgroundColorSpan(Color.YELLOW), ForegroundColorSpan(Color.BLACK)) }
private const val CONDENSED_MODE_MAX_LINES = 3
private val footerCorners = Projection.Corners(18f.dp)
private val transparentChatColors = ChatColors.forColor(ChatColors.Id.NotSet, Color.TRANSPARENT)
}
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
override val root: ViewGroup = binding.root
override val bubbleView: View = binding.conversationItemBodyWrapper
override val bubbleViews: List<View> = 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
private var reactionMeasureListener: ReactionMeasureListener = ReactionMeasureListener()
private val bodyBubbleDrawable = ChatColorsDrawable()
private val footerDrawable = ChatColorsDrawable()
init {
binding.root.addOnMeasureListener(footerDelegate)
binding.root.onDispatchTouchEventListener = dispatchTouchEventListener
binding.conversationItemReactions.setOnClickListener {
conversationContext.clickListener
.onReactionClicked(
Multiselect.getParts(conversationMessage).asSingle().singlePart,
conversationMessage.messageRecord.id,
conversationMessage.messageRecord.isMms
)
}
binding.root.setOnClickListener {
conversationContext.clickListener.onItemClick(getMultiselectPartForLatestTouch())
}
binding.root.setOnLongClickListener {
conversationContext.clickListener.onItemLongClick(binding.root, getMultiselectPartForLatestTouch())
true
}
val passthroughClickListener = PassthroughClickListener()
binding.conversationItemBody.setOnClickListener(passthroughClickListener)
binding.conversationItemBody.setOnLongClickListener(passthroughClickListener)
binding.conversationItemBody.isFocusable = false
binding.conversationItemBody.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().messageFontSize.toFloat())
binding.conversationItemBody.movementMethod = LongClickMovementMethod.getInstance(context)
if (binding.isIncoming) {
binding.conversationItemBody.setMentionBackgroundTint(ContextCompat.getColor(context, if (ThemeUtil.isDarkTheme(context)) R.color.core_grey_60 else R.color.core_grey_20))
} else {
binding.conversationItemBody.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25))
}
binding.conversationItemBodyWrapper.background = bodyBubbleDrawable
binding.conversationItemBodyWrapper.layoutTransition = BodyBubbleLayoutTransition()
binding.conversationItemFooterBackground.background = footerDrawable
}
override fun invalidateChatColorsDrawable(coordinateRoot: ViewGroup) {
invalidateBodyBubbleDrawable(coordinateRoot)
invalidateFooterDrawable(coordinateRoot)
}
override fun bind(model: Model) {
check(model is ConversationMessageElement)
conversationMessage = model.conversationMessage
val shape = shapeDelegate.setMessageShape(
isLtr = itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR,
currentMessage = conversationMessage.messageRecord,
isGroupThread = conversationMessage.threadRecipient.isGroup,
adapterPosition = bindingAdapterPosition
)
presentBody()
presentDate(shape)
presentDeliveryStatus(shape)
presentFooterBackground(shape)
presentFooterExpiry(shape)
presentAlert()
presentSender()
presentReactions()
bodyBubbleDrawable.setChatColors(
if (binding.conversationItemBody.isJumbomoji) {
transparentChatColors
} else if (binding.isIncoming) {
ChatColors.forColor(ChatColors.Id.NotSet, themeDelegate.getBodyBubbleColor(conversationMessage))
} else {
conversationMessage.threadRecipient.chatColors
},
shapeDelegate.corners
)
binding.conversationItemReply.setBackgroundColor(themeDelegate.getReplyIconBackgroundColor())
itemView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = shape.topPadding.toInt()
bottomMargin = shape.bottomPadding.toInt()
}
}
override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList {
return getSnapshotProjections(coordinateRoot, clipOutMedia, true)
}
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean, outgoingOnly: Boolean): ProjectionList {
projections.clear()
if (outgoingOnly && binding.isIncoming) {
return projections
}
projections.add(
Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
Projection.Corners.NONE
).translateX(binding.conversationItemBodyWrapper.translationX).translateY(root.translationY)
)
return projections
}
override fun getSnapshotStrategy(): InteractiveConversationElement.SnapshotStrategy {
return V2TextOnlySnapshotStrategy(binding)
}
/**
* Note: This is not necessary for CFV2 Text-Only items because the background is rendered by
* [ChatColorsDrawable]
*/
override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList {
projections.clear()
return projections
}
override fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.top
}
override fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int {
return root.bottom
}
override fun getMultiselectPartForLatestTouch(): MultiselectPart {
return conversationMessage.multiselectCollection.asSingle().singlePart
}
override fun getHorizontalTranslationTarget(): View? {
return if (conversationMessage.messageRecord.isOutgoing) {
null
} else if (conversationMessage.threadRecipient.isGroup) {
binding.senderPhoto
} else {
binding.conversationItemBodyWrapper
}
}
override fun hasNonSelectableMedia(): Boolean = false
override fun showProjectionArea() = Unit
override fun hideProjectionArea() = Unit
override fun getGiphyMp4PlayableProjection(coordinateRoot: ViewGroup): Projection {
return Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
)
.translateY(root.translationY)
.translateX(binding.conversationItemBodyWrapper.translationX)
.translateX(root.translationX)
}
override fun canPlayContent(): Boolean = false
override fun shouldProjectContent(): Boolean = false
private fun invalidateFooterDrawable(coordinateRoot: ViewGroup) {
if (footerDrawable.isSolidColor()) {
return
}
val projection = Projection.relativeToParent(
coordinateRoot,
binding.conversationItemFooterBackground,
shapeDelegate.corners
)
footerDrawable.applyMaskProjection(projection)
projection.release()
}
private fun invalidateBodyBubbleDrawable(coordinateRoot: ViewGroup) {
if (bodyBubbleDrawable.isSolidColor()) {
return
}
val projection = Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
)
bodyBubbleDrawable.applyMaskProjection(projection)
projection.release()
}
private fun MessageRecord.buildMessageId(): Long {
return if (isMms) -id else id
}
private fun presentBody() {
binding.conversationItemBody.setTextColor(themeDelegate.getBodyTextColor(conversationMessage))
binding.conversationItemBody.setLinkTextColor(themeDelegate.getBodyTextColor(conversationMessage))
val record = conversationMessage.messageRecord
var styledText: Spannable = conversationMessage.getDisplayBody(context)
if (conversationContext.isMessageRequestAccepted) {
linkifyMessageBody(styledText)
}
styledText = SearchUtil.getHighlightedSpan(Locale.getDefault(), STYLE_FACTORY, styledText, conversationContext.searchQuery, SearchUtil.STRICT)
if (record.hasExtraText()) {
binding.conversationItemBody.setOverflowText(getLongMessageSpan())
} else {
binding.conversationItemBody.setOverflowText(null)
}
if (isContentCondensed()) {
binding.conversationItemBody.maxLines = CONDENSED_MODE_MAX_LINES
} else {
binding.conversationItemBody.maxLines = Integer.MAX_VALUE
}
binding.conversationItemBody.text = StringUtil.trim(styledText)
}
private fun linkifyMessageBody(messageBody: Spannable) {
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
if (conversationMessage.hasStyleLinks()) {
messageBody.getSpans(0, messageBody.length, PlaceholderURLSpan::class.java).forEach { placeholder ->
val start = messageBody.getSpanStart(placeholder)
val end = messageBody.getSpanEnd(placeholder)
val span: URLSpan = InterceptableLongClickCopyLinkSpan(
placeholder.value,
conversationContext.clickListener::onUrlClicked,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false
)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MentionAnnotation.getMentionAnnotations(messageBody).forEach { annotation ->
messageBody.setSpan(
MentionClickableSpan(RecipientId.from(annotation.value)),
messageBody.getSpanStart(annotation),
messageBody.getSpanEnd(annotation),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
private fun getLongMessageSpan(): CharSequence {
val message = context.getString(R.string.ConversationItem_read_more)
val span = SpannableStringBuilder(message)
span.setSpan(ReadMoreSpan(), 0, span.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return span
}
private inner class MentionClickableSpan(
private val recipientId: RecipientId
) : ClickableSpan() {
override fun onClick(widget: View) {
VibrateUtil.vibrateTick(context)
conversationContext.clickListener.onGroupMemberClicked(recipientId, conversationMessage.threadRecipient.requireGroupId())
}
override fun updateDrawState(ds: TextPaint) = Unit
}
private inner class ReadMoreSpan : ClickableSpan() {
override fun onClick(widget: View) {
if (conversationContext.selectedItems.isEmpty()) {
conversationContext.clickListener.onMoreTextClicked(
conversationMessage.threadRecipient.id,
conversationMessage.messageRecord.id,
conversationMessage.messageRecord.isMms
)
}
}
override fun updateDrawState(ds: TextPaint) {
ds.typeface = Typeface.DEFAULT_BOLD
}
}
private fun isContentCondensed(): Boolean {
return conversationContext.displayMode == ConversationItemDisplayMode.CONDENSED && conversationContext.getPreviousMessage(bindingAdapterPosition) == null
}
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(conversationContext.glideRequests, sender, false)
binding.senderBadge.setBadgeFromRecipient(sender, conversationContext.glideRequests)
} 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 presentReactions() {
if (conversationMessage.messageRecord.reactions.isEmpty()) {
binding.conversationItemReactions.clear()
binding.root.removeOnMeasureListener(reactionMeasureListener)
} else {
reactionMeasureListener.onPostMeasure()
binding.root.addOnMeasureListener(reactionMeasureListener)
}
}
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
footerDrawable.setChatColors(
if (binding.isIncoming) {
ChatColors.forColor(ChatColors.Id.NotSet, themeDelegate.getFooterBubbleColor(conversationMessage))
} else {
conversationMessage.threadRecipient.chatColors
},
footerCorners
)
}
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 = conversationMessage.formattedDate
} else {
var date = conversationMessage.formattedDate
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
}
private inner class ReactionMeasureListener : V2ConversationItemLayout.OnMeasureListener {
override fun onPreMeasure() = Unit
override fun onPostMeasure(): Boolean {
return binding.conversationItemReactions.setReactions(conversationMessage.messageRecord.reactions, binding.conversationItemBodyWrapper.width)
}
}
private inner class PassthroughClickListener : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View?) {
binding.root.performClick()
}
override fun onLongClick(v: View?): Boolean {
if (binding.conversationItemBody.hasSelection()) {
return false
}
binding.root.performLongClick()
return true
}
}
}