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 <charles@oxen.io>
Co-authored-by: 0x330a <92654767+0x330a@users.noreply.github.com>
Co-authored-by: ThomasSession <thomas.r@getsession.org>
This commit is contained in:
ceokot 2024-07-01 14:52:18 +10:00 committed by GitHub
parent 1d80bb0ba9
commit a260717d42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 221 additions and 49 deletions

View File

@ -1958,7 +1958,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val messageIterator = sortedMessages.iterator() val messageIterator = sortedMessages.iterator()
while (messageIterator.hasNext()) { while (messageIterator.hasNext()) {
val message = messageIterator.next() 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 (TextUtils.isEmpty(body)) { continue }
if (messageSize > 1) { if (messageSize > 1) {
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)

View File

@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.text = authorDisplayName
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
// Body // 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)) binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))
// Accent line / attachment preview // Accent line / attachment preview
val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing

View File

@ -282,7 +282,12 @@ class VisibleMessageContentView : ConstraintLayout {
fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable {
var body = message.body.toSpannable() 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(), body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
{ BackgroundColorSpan(Color.WHITE) }, body, searchQuery) { BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), body = SearchUtil.getHighlightedSpan(Locale.getDefault(),

View File

@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Application
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Range import android.util.Range
import androidx.appcompat.widget.ThemeUtils
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2 import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact 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.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil 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.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.getAccentColor
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
import java.util.regex.Pattern import java.util.regex.Pattern
object MentionUtilities { object MentionUtilities {
@JvmStatic private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") }
fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String {
return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
}
/**
* 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 @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 @Suppress("NAME_SHADOWING") var text = text
val pattern = Pattern.compile("@[0-9a-fA-F]*")
var matcher = pattern.matcher(text) var matcher = pattern.matcher(text)
val mentions = mutableListOf<Tuple2<Range<Int>, String>>() val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
var startIndex = 0 var startIndex = 0
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! 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)) { if (matcher.find(startIndex)) {
while (true) { while (true) {
val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ 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 isYou = isYou(publicKey, userPublicKey, openGroup)
val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { val userDisplayName: String? = if (isYou) {
context.getString(R.string.MessageRecord_you) context.getString(R.string.MessageRecord_you)
} else { } else {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
@ -53,7 +70,8 @@ object MentionUtilities {
contact?.displayName(context) contact?.displayName(context)
} }
if (userDisplayName != null) { 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 val endIndex = matcher.start() + 1 + userDisplayName.length
startIndex = endIndex startIndex = endIndex
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey))
@ -66,37 +84,83 @@ object MentionUtilities {
} }
val result = SpannableString(text) val result = SpannableString(text)
var mentionTextColour: Int? = null // apply styling if required
// In dark themes.. // Normal text color: black in dark mode and primary text color for light mode
if (ThemeUtil.isDarkTheme(context)) { val mainTextColor by lazy {
// ..we use the standard outgoing message colour for outgoing messages.. if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black)
if (isOutgoingMessage) { else context.getColorFromAttr(android.R.attr.textColorPrimary)
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)
} }
// 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(!formatOnly) {
for (mention in mentions) { for (mention in mentions) {
result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val backgroundColor: Int?
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val foregroundColor: Int?
// If we're using a light theme then we change the background colour of the mention to be the accent colour // quotes
if (ThemeUtil.isLightTheme(context)) { if(isQuote) {
val backgroundColour = context.getAccentColor(); backgroundColor = null
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // 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 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
}
} }

View File

@ -103,7 +103,12 @@ class ConversationView : LinearLayout {
R.drawable.ic_notifications_mentions R.drawable.ic_notifications_mentions
} }
binding.muteIndicatorImageView.setImageResource(drawableRes) 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.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) { if (isTyping) {

View File

@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout {
binding.displayNameTextView.text = senderDisplayName binding.displayNameTextView.text = senderDisplayName
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
val rawSnippet = thread.getDisplayBody(context) 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 binding.snippetTextView.text = snippet
post { post {

View File

@ -444,13 +444,30 @@ public class DefaultMessageNotifier implements MessageNotifier {
while(iterator.hasPrevious()) { while(iterator.hasPrevious()) {
NotificationItem item = iterator.previous(); NotificationItem item = iterator.previous();
builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), 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) { if (signal) {
builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate());
CharSequence text = notifications.get(0).getText();
builder.setTicker(notifications.get(0).getIndividualRecipient(), 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); builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag);

View File

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