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()
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)

View File

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

View File

@ -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(),

View File

@ -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<Tuple2<Range<Int>, 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)
}
// 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) {
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)
val backgroundColor: Int?
val foregroundColor: Int?
// 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)
// 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
}
}

View File

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

View File

@ -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 {

View File

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

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