CFV2 Add proper body presentation code.

This commit is contained in:
Alex Hart
2023-07-06 14:34:14 -03:00
committed by Clark Chen
parent c6473ca9e6
commit 5ea4cbf9ca
6 changed files with 191 additions and 28 deletions

View File

@@ -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);

View File

@@ -72,7 +72,7 @@ class ConversationAdapterV2(
override val selectedItems: Set<MultiselectPart>
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)

View File

@@ -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)
}
}
}

View File

@@ -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<MultiselectPart>
val isMessageRequestAccepted: Boolean
val searchQuery: String?
fun onStartExpirationTimeout(messageRecord: MessageRecord)

View File

@@ -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<Model : MappingModel<Model>>(
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 var messageId: Long = Long.MAX_VALUE
private val projections = ProjectionList()
@@ -107,6 +137,17 @@ class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
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<Model : MappingModel<Model>>(
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<Model : MappingModel<Model>>(
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()