From 5ea4cbf9caf20c4dcc16154011a7e80d50ceb710 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 6 Jul 2023 14:34:14 -0300 Subject: [PATCH] CFV2 Add proper body presentation code. --- .../conversation/ConversationItem.java | 26 +--- .../conversation/v2/ConversationAdapterV2.kt | 5 +- .../v2/items/V2ConversationBodyUtil.kt | 44 ++++++ .../v2/items/V2ConversationContext.kt | 4 + .../v2/items/V2ConversationItemViewHolder.kt | 137 +++++++++++++++++- .../ConversationItemTest_linkifyUrlLinks.kt | 3 +- 6 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationBodyUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index e405116abe..cf11ddb080 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -100,6 +100,7 @@ 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.conversation.v2.items.V2ConversationBodyUtil; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.MediaTable; import org.thoughtcrime.securesms.database.MessageTable; @@ -1509,7 +1510,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private void linkifyMessageBody(@NonNull Spannable messageBody, boolean shouldLinkifyAllLinks) { - linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener); + V2ConversationBodyUtil.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener); if (conversationMessage.hasStyleLinks()) { for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) { @@ -1529,29 +1530,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } - - @VisibleForTesting - static void linkifyUrlLinks(@NonNull Spannable messageBody, boolean shouldLinkifyAllLinks, @NonNull UrlClickHandler urlClickHandler) { - int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS; - boolean hasLinks = LinkifyCompat.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0); - - if (hasLinks) { - Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class)) - .filterNot(url -> LinkUtil.isLegalUrl(url.getURL())) - .forEach(messageBody::removeSpan); - - URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class); - - for (URLSpan urlSpan : urlSpans) { - int start = messageBody.getSpanStart(urlSpan); - int end = messageBody.getSpanEnd(urlSpan); - URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickHandler); - messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - messageBody.removeSpan(urlSpan); - } - } - } - private void setStatusIcons(MessageRecord messageRecord, boolean hasWallpaper) { bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0); 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 a7e349368b..f70568f32c 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 @@ -72,7 +72,7 @@ class ConversationAdapterV2( override val selectedItems: Set get() = _selected.toSet() - private var searchQuery: String? = null + override var searchQuery: String? = null private var inlineContent: ConversationMessage? = null private var recordToPulse: ConversationMessage? = null @@ -80,6 +80,9 @@ class ConversationAdapterV2( private val condensedMode: ConversationItemDisplayMode? = null + // TODO [cfv2] + override val isMessageRequestAccepted: Boolean = true + init { registerFactory(ThreadHeader::class.java, ::ThreadHeaderViewHolder, R.layout.conversation_item_thread_header) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationBodyUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationBodyUtil.kt new file mode 100644 index 0000000000..987475db6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationBodyUtil.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.text.Spannable +import android.text.Spanned +import android.text.style.URLSpan +import android.text.util.Linkify +import androidx.core.text.util.LinkifyCompat +import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan +import org.thoughtcrime.securesms.util.LinkUtil +import org.thoughtcrime.securesms.util.UrlClickHandler + +/** + * Utilities for presenting the body of a conversation message. + */ +object V2ConversationBodyUtil { + + @JvmStatic + fun linkifyUrlLinks(messageBody: Spannable, shouldLinkifyAllLinks: Boolean, urlClickHandler: UrlClickHandler) { + val linkPattern = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS + val hasLinks = LinkifyCompat.addLinks(messageBody, if (shouldLinkifyAllLinks) linkPattern else 0) + + if (!hasLinks) { + return + } + + messageBody.getSpans(0, messageBody.length, URLSpan::class.java) + .filterNot { LinkUtil.isLegalUrl(it.url) } + .forEach(messageBody::removeSpan) + + messageBody.getSpans(0, messageBody.length, URLSpan::class.java).forEach { urlSpan -> + val start = messageBody.getSpanStart(urlSpan) + val end = messageBody.getSpanEnd(urlSpan) + val span = InterceptableLongClickCopyLinkSpan(urlSpan.url, urlClickHandler) + + messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + messageBody.removeSpan(urlSpan) + } + } +} 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 index 5aba61a441..22c3cf5e41 100644 --- 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 @@ -8,6 +8,7 @@ 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.conversation.mutiselect.MultiselectPart import org.thoughtcrime.securesms.database.model.MessageRecord /** @@ -17,6 +18,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord interface V2ConversationContext { val displayMode: ConversationItemDisplayMode val clickListener: ConversationAdapter.ItemClickListener + val selectedItems: Set + val isMessageRequestAccepted: Boolean + val searchQuery: String? fun onStartExpirationTimeout(messageRecord: MessageRecord) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt index 4f43fbc255..e5ffe49125 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt @@ -5,15 +5,30 @@ 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 com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel +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.ConversationItemDisplayMode import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect @@ -23,12 +38,22 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElemen 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.DateUtils +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.hasNoBubble import org.thoughtcrime.securesms.util.isScheduled import org.thoughtcrime.securesms.util.visible @@ -53,6 +78,11 @@ class V2TextOnlyViewHolder>( private val conversationContext: V2ConversationContext ) : V2BaseViewHolder(binding.root, conversationContext), Multiselectable, InteractiveConversationElement { + companion object { + private val STYLE_FACTORY = StyleFactory { arrayOf(BackgroundColorSpan(Color.YELLOW), ForegroundColorSpan(Color.BLACK)) } + private const val CONDENSED_MODE_MAX_LINES = 3 + } + private var messageId: Long = Long.MAX_VALUE private val projections = ProjectionList() @@ -107,6 +137,17 @@ class V2TextOnlyViewHolder>( true } + + binding.conversationItemBody.isClickable = false + 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)) + } } override fun bind(model: Model) { @@ -120,13 +161,12 @@ class V2TextOnlyViewHolder>( adapterPosition = 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()) + presentBody() presentDate(shape) presentDeliveryStatus(shape) presentFooterBackground(shape) @@ -234,6 +274,99 @@ class V2TextOnlyViewHolder>( 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() diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationItemTest_linkifyUrlLinks.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationItemTest_linkifyUrlLinks.kt index 21e8b7ce6d..c7a94a49fa 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationItemTest_linkifyUrlLinks.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationItemTest_linkifyUrlLinks.kt @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationBodyUtil import org.thoughtcrime.securesms.util.UrlClickHandler @Suppress("ClassName") @@ -19,7 +20,7 @@ class ConversationItemTest_linkifyUrlLinks(private val input: String, private va fun test1() { val spannableStringBuilder = SpannableStringBuilder(input) - ConversationItem.linkifyUrlLinks(spannableStringBuilder, true, UrlHandler) + V2ConversationBodyUtil.linkifyUrlLinks(spannableStringBuilder, true, UrlHandler) val spans = spannableStringBuilder.getSpans(0, expectedUrl.length, URLSpan::class.java) assertEquals(1, spans.size)