From a260717d423ee33f8d4325b001eb9d6b9d538633 Mon Sep 17 00:00:00 2001 From: ceokot Date: Mon, 1 Jul 2024 14:52:18 +1000 Subject: [PATCH] Highlight @You mentions (#985) * Highlight @You mentions * fix: resolve merge conflicts * Setting the proper design rules for mentions * New RoundedBackgroundSpan, applied to "you" mentions The rounded background highlighter can take padding, so there is no need to add those extra spaces at the start and end. * Better mention highlight logic Some mention highlight should only format the text and not apply any styling. Also making sure we cater for all cases properly * Updated the text color logic based on design rules * Fine tuning the color rules * Removing usage of Resources.getSystem() Only making the db call if there actually is a mention * Moving color definition outside the loop to avoid repetitions --------- Co-authored-by: charles Co-authored-by: 0x330a <92654767+0x330a@users.noreply.github.com> Co-authored-by: ThomasSession --- .../conversation/v2/ConversationActivityV2.kt | 8 +- .../conversation/v2/messages/QuoteView.kt | 10 +- .../v2/messages/VisibleMessageContentView.kt | 7 +- .../v2/utilities/MentionUtilities.kt | 148 +++++++++++++----- .../securesms/home/ConversationView.kt | 7 +- .../messagerequests/MessageRequestView.kt | 8 +- .../notifications/DefaultMessageNotifier.java | 21 ++- .../securesms/util/RoundedBackgroundSpan.kt | 61 ++++++++ 8 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 187ded770e..d5e8c43fd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1958,7 +1958,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() - val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) + val body = MentionUtilities.highlightMentions( + text = message.body, + formatOnly = true, // no styling here, only text formatting + threadID = viewModel.threadId, + context = this + ) + if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 927ad5f60f..c4a29fea54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) // Body - binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context) + binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) + resources.getString(R.string.open_group_invitation_view__open_group_invitation) + else MentionUtilities.highlightMentions( + text = (body ?: "").toSpannable(), + isOutgoingMessage = isOutgoingMessage, + isQuote = true, + threadID = threadID, + context = context + ) binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 7e220955d6..baf80e56aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -282,7 +282,12 @@ class VisibleMessageContentView : ConstraintLayout { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() - body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) + body = MentionUtilities.highlightMentions( + text = body, + isOutgoingMessage = message.isOutgoing, + threadID = message.threadId, + context = context + ) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index cb9a19ffc1..3edfcffc97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import android.app.Application import android.content.Context +import android.graphics.Color import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString @@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range -import androidx.appcompat.widget.ThemeUtils import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil -import org.session.libsignal.utilities.Log +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr -import org.thoughtcrime.securesms.util.getMessageTextColourAttr +import org.thoughtcrime.securesms.util.toPx import java.util.regex.Pattern object MentionUtilities { - @JvmStatic - fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { - return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant - } + private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") } + /** + * Highlights mentions in a given text. + * + * @param text The text to highlight mentions in. + * @param isOutgoingMessage Whether the message is outgoing. + * @param isQuote Whether the message is a quote. + * @param formatOnly Whether to only format the mentions. If true we only format the text itself, + * for example resolving an accountID to a username. If false we also apply styling, like colors and background. + * @param threadID The ID of the thread the message belongs to. + * @param context The context to use. + * @return A SpannableString with highlighted mentions. + */ @JvmStatic - fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { + fun highlightMentions( + text: CharSequence, + isOutgoingMessage: Boolean = false, + isQuote: Boolean = false, + formatOnly: Boolean = false, + threadID: Long, + context: Context + ): SpannableString { @Suppress("NAME_SHADOWING") var text = text - val pattern = Pattern.compile("@[0-9a-fA-F]*") + var matcher = pattern.matcher(text) val mentions = mutableListOf, String>>() var startIndex = 0 val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID) + val openGroup by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) } + + // format the mention text if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false - val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { + val isYou = isYou(publicKey, userPublicKey, openGroup) + val userDisplayName: String? = if (isYou) { context.getString(R.string.MessageRecord_you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) @@ -53,7 +70,8 @@ object MentionUtilities { contact?.displayName(context) } if (userDisplayName != null) { - text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length) + val mention = "@$userDisplayName" + text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) @@ -66,37 +84,83 @@ object MentionUtilities { } val result = SpannableString(text) - var mentionTextColour: Int? = null - // In dark themes.. - if (ThemeUtil.isDarkTheme(context)) { - // ..we use the standard outgoing message colour for outgoing messages.. - if (isOutgoingMessage) { - val mentionTextColourAttributeId = getMessageTextColourAttr(true) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) - } - else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us).. - { - mentionTextColour = context.getAccentColor() - } - } - else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions. - { - val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) + // apply styling if required + // Normal text color: black in dark mode and primary text color for light mode + val mainTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black) + else context.getColorFromAttr(android.R.attr.textColorPrimary) } - for (mention in mentions) { - result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + // Highlighted text color: primary/accent in dark mode and primary text color for light mode + val highlightedTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() + else context.getColorFromAttr(android.R.attr.textColorPrimary) + } - // If we're using a light theme then we change the background colour of the mention to be the accent colour - if (ThemeUtil.isLightTheme(context)) { - val backgroundColour = context.getAccentColor(); - result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if(!formatOnly) { + for (mention in mentions) { + val backgroundColor: Int? + val foregroundColor: Int? + + // quotes + if(isQuote) { + backgroundColor = null + // the text color has different rule depending if the message is incoming or outgoing + foregroundColor = if(isOutgoingMessage) null else highlightedTextColor + } + // incoming message mentioning you + else if (isYou(mention.second, userPublicKey, openGroup)) { + backgroundColor = context.getAccentColor() + foregroundColor = mainTextColor + } + // outgoing message + else if (isOutgoingMessage) { + backgroundColor = null + foregroundColor = mainTextColor + } + // incoming messages mentioning someone else + else { + backgroundColor = null + // accent color for dark themes and primary text for light + foregroundColor = highlightedTextColor + } + + // apply the background, if any + backgroundColor?.let { background -> + result.setSpan( + RoundedBackgroundSpan( + context = context, + textColor = mainTextColor, + backgroundColor = background + ), + mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply the foreground, if any + foregroundColor?.let { + result.setSpan( + ForegroundColorSpan(it), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply bold on the mention + result.setSpan( + StyleSpan(Typeface.BOLD), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } } return result } + + private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c9896a5b8e..36ea3a4371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -103,7 +103,12 @@ class ConversationView : LinearLayout { R.drawable.ic_notifications_mentions } binding.muteIndicatorImageView.setImageResource(drawableRes) - binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context) + binding.snippetTextView.text = highlightMentions( + text = thread.getSnippet(), + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index af3d269c6a..a916d8e4d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout { binding.displayNameTextView.text = senderDisplayName binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val rawSnippet = thread.getDisplayBody(context) - val snippet = highlightMentions(rawSnippet, thread.threadId, context) + val snippet = highlightMentions( + text = rawSnippet, + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) + binding.snippetTextView.text = snippet post { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index b281e0798b..6aa514c8a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -444,13 +444,30 @@ public class DefaultMessageNotifier implements MessageNotifier { while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions(item.getText(), item.getThreadId(), context)); + MentionUtilities.highlightMentions( + item.getText() != null ? item.getText() : "", + false, + false, + true, // no styling here, only text formatting + item.getThreadId(), + context + ) + ); } if (signal) { builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); + CharSequence text = notifications.get(0).getText(); builder.setTicker(notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context)); + MentionUtilities.highlightMentions( + text != null ? text : "", + false, + false, + true, // no styling here, only text formatting + notifications.get(0).getThreadId(), + context + ) + ); } builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt new file mode 100644 index 0000000000..ebefc9c50c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.style.ReplacementSpan + +/** + * A Span that draws text with a rounded background. + * + * @param textColor - The color of the text. + * @param backgroundColor - The color of the background. + * @param cornerRadius - The corner radius of the background in pixels. Defaults to 8dp. + * @param paddingHorizontal - The horizontal padding of the text in pixels. Defaults to 3dp. + * @param paddingVertical - The vertical padding of the text in pixels. Defaults to 3dp. + */ + + +class RoundedBackgroundSpan( + context: Context, + private val textColor: Int, + private val backgroundColor: Int, + private val cornerRadius: Float = toPx(8, context.resources).toFloat(), // setting some Session defaults + private val paddingHorizontal: Float = toPx(3, context.resources).toFloat(), + private val paddingVertical: Float = toPx(3, context.resources).toFloat() +) : ReplacementSpan() { + + override fun draw( + canvas: Canvas, text: CharSequence, start: Int, end: Int, + x: Float, top: Int, y: Int, bottom: Int, paint: Paint + ) { + // the top needs to take into account the font and the required vertical padding + val newTop = y + paint.fontMetrics.ascent - paddingVertical + val newBottom = y + paint.fontMetrics.descent + paddingVertical + val rect = RectF( + x, + newTop, + x + measureText(paint, text, start, end) + 2 * paddingHorizontal, + newBottom + ) + paint.color = backgroundColor + + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = textColor + canvas.drawText(text, start, end, x + paddingHorizontal, y.toFloat(), paint) + } + + override fun getSize( + paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt? + ): Int { + return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt() + } + + private fun measureText( + paint: Paint, text: CharSequence, start: Int, end: Int + ): Float { + return paint.measureText(text, start, end) + } +}