Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c75f693
Paging and compose message list
ThomasSession Feb 26, 2026
f577551
Mapping to MessageViewData
ThomasSession Feb 27, 2026
8719d24
Merge branch 'dev' into feature/ConvoV3-pt2
ThomasSession Feb 27, 2026
785dfbf
More mapping and UI
ThomasSession Feb 27, 2026
7539f48
MEssage spacing
ThomasSession Feb 27, 2026
9d2932b
showing unread marker
ThomasSession Mar 2, 2026
1e55a2b
Adding pro badge and display name extra to messages
ThomasSession Mar 2, 2026
92801e3
Updated "end cluster" logic
ThomasSession Mar 2, 2026
7b62901
Merge branch 'dev' into feature/ConvoV3-pt2
ThomasSession Mar 2, 2026
2730542
Community Invite - Restructuring message types
ThomasSession Mar 2, 2026
52316d2
highlight hard typed
ThomasSession Mar 2, 2026
c1dcd9c
Reworked overall UI data structure to allow for flexible building blocks
ThomasSession Mar 3, 2026
b018f5b
Logic and data structure update
ThomasSession Mar 3, 2026
4cc6a77
Fixes
ThomasSession Mar 3, 2026
e8c73a6
More UI additions
ThomasSession Mar 3, 2026
d871fc6
Rich text
ThomasSession Mar 3, 2026
2ce3398
rich text update
ThomasSession Mar 3, 2026
ef4c7ca
autolink + quote styling
ThomasSession Mar 3, 2026
1a9a7c5
Updated rich text logic
ThomasSession Mar 3, 2026
5bb99fc
optional link handling
ThomasSession Mar 3, 2026
a376043
Renamed Message text and formatter
ThomasSession Mar 4, 2026
1cc6174
Comments for readability
ThomasSession Mar 4, 2026
0e64159
Merge branch 'dev' into feature/ConvoV3-pt2
ThomasSession Mar 4, 2026
ded4a6a
control message WIP
ThomasSession Mar 4, 2026
b05925e
PR feedback
ThomasSession Mar 4, 2026
7364e5e
PR feedback
ThomasSession Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ dependencies {
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.interpolator)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)

// Add firebase dependencies to specific variants
for (variant in firebaseEnabledVariants) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ import org.thoughtcrime.securesms.conversation.v3.settings.notification.Notifica
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.conversation.v3.ConversationActivityV3
import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.GroupDatabase
Expand Down Expand Up @@ -1059,6 +1060,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
onSearchQueryChanged = ::onSearchQueryUpdated,
onSearchQueryClear = { onSearchQueryUpdated("") },
onSearchCanceled = ::onSearchClosed,
switchConvoVersion = {
startActivity(ConversationActivityV3.createIntent(this, address = IntentCompat.getParcelableExtra(intent,
ADDRESS, Address.Conversable::class.java)!!))
finish()
},
onAvatarPressed = {
val intent = ConversationSettingsActivity.createIntent(this, address)
settingsLauncher.launch(intent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
ProBadgeText(
modifier = modifier,
text = authorDisplayName,
textStyle = LocalType.current.small.bold().copy(color = Color(textColor)),
textStyle = LocalType.current.base.bold().copy(color = Color(textColor)),
showBadge = authorRecipient.shouldShowProBadge,
badgeColors = if(isOutgoingMessage && mode == Mode.Regular) proBadgeColorOutgoing()
else proBadgeColorStandard()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Range
import network.loki.messenger.R
import org.session.libsession.utilities.Address.Companion.toAddress
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
Expand All @@ -17,6 +15,7 @@ import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.database.RecipientRepository
import org.thoughtcrime.securesms.util.RoundedBackgroundSpan
import org.thoughtcrime.securesms.util.getAccentColor
import network.loki.messenger.R
import java.util.regex.Pattern

object MentionUtilities {
Expand All @@ -27,152 +26,201 @@ object MentionUtilities {
/**
* In-place replacement on the *live* MentionEditable that the
* input-bar is already using.
*
* It swaps every "@<64-hex>" token for "@DisplayName" **and**
* attaches a MentionSpan so later normalisation still works.
*/
fun substituteIdsInPlace(
editable: MentionEditable,
membersById: Map<String, MentionViewModel.Member>
) {
ACCOUNT_ID.findAll(editable)
.toList() // avoid index shifts
.asReversed() // back-to-front replacement
.toList() // avoid index shifts
.asReversed() // back-to-front replacement
.forEach { m ->
val id = m.groupValues[1]
val member = membersById[id] ?: return@forEach
val id = m.groupValues[1]
val member = membersById[id] ?: return@forEach

val start = m.range.first
val end = m.range.last + 1 // inclusive ➜ exclusive
val end = m.range.last + 1 // inclusive ➜ exclusive

editable.replace(start, end, "@${member.name}")
editable.addMention(member, start .. start + member.name.length + 1)
editable.addMention(member, start..start + member.name.length + 1)
}
}

// ----------------------------
// Shared parsing/substitution core
// ----------------------------

data class MentionToken(
val start: Int, // start in FINAL substituted text
val endExclusive: Int, // end-exclusive in FINAL substituted text
val publicKey: String,
val isSelf: Boolean
)

data class ParsedMentions(
val text: String,
val mentions: List<MentionToken>
)

/**
* Highlights mentions in a given text.
* Shared core:
* - replaces "@<66-hex>" with "@DisplayName"
* - returns the final text + mention ranges (in that final text) + metadata
*
* @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 context The context to use.
* @return A SpannableString with highlighted mentions.
* This is UI-agnostic and is used by BOTH:
* - legacy XML span formatting
* - Compose rich text formatting
*/
@JvmStatic
fun highlightMentions(
fun parseAndSubstituteMentions(
recipientRepository: RecipientRepository,
text: CharSequence,
isOutgoingMessage: Boolean = false,
isQuote: Boolean = false,
formatOnly: Boolean = false,
input: CharSequence,
context: Context
): SpannableString {
@Suppress("NAME_SHADOWING") var text = text
): ParsedMentions {
@Suppress("NAME_SHADOWING")
var text: CharSequence = input

var matcher = pattern.matcher(text)
val mentions = mutableListOf<Pair<Range<Int>, String>>()
val mentions = mutableListOf<MentionToken>()
var startIndex = 0

// 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 user = recipientRepository.getRecipientSync(publicKey.toAddress())
val publicKey =
text.subSequence(matcher.start() + 1, matcher.end()).toString() // drop '@'

val userDisplayName: String = if (user.isSelf) {
val user = recipientRepository.getRecipientSync(publicKey.toAddress())
val displayName = if (user.isSelf) {
context.getString(R.string.you)
} else {
user.displayName(attachesBlindedId = true)
}

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(Pair(Range.create(matcher.start(), endIndex), publicKey))
val replacement = "@$displayName"

val newText = buildString(
text.length - (matcher.end() - matcher.start()) + replacement.length
) {
append(text.subSequence(0, matcher.start()))
append(replacement)
append(text.subSequence(matcher.end(), text.length))
}

val start = matcher.start()
val endExclusive = start + replacement.length

mentions += MentionToken(
start = start,
endExclusive = endExclusive,
publicKey = publicKey,
isSelf = user.isSelf
)

text = newText
startIndex = endExclusive

matcher = pattern.matcher(text)
if (!matcher.find(startIndex)) { break }
if (!matcher.find(startIndex)) break
}
}
val result = SpannableString(text)

// apply styling if required
return ParsedMentions(
text = text.toString(),
mentions = mentions
)
}

// ----------------------------
// Legacy (XML/TextView) formatter
// ----------------------------

/**
* Legacy (XML/TextView) formatter.
*
* Highlights mentions in a given text.
*
* @param formatOnly If true we only format the text itself,
* for example resolving an accountID to a username. If false we also apply styling.
*/
@JvmStatic
fun highlightMentions(
recipientRepository: RecipientRepository,
text: CharSequence,
isOutgoingMessage: Boolean = false,
isQuote: Boolean = false,
formatOnly: Boolean = false,
context: Context
): SpannableString {
val parsed = parseAndSubstituteMentions(recipientRepository, text, context)
val result = SpannableString(parsed.text)

if (formatOnly) return result

// 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
// Highlighted text color: accent in dark theme and primary text for light
val highlightedTextColor by lazy {
if (ThemeUtil.isDarkTheme(context)) context.getAccentColor()
else context.getColorFromAttr(android.R.attr.textColorPrimary)
}

if(!formatOnly) {
for (mention in mentions) {
val backgroundColor: Int?
val foregroundColor: Int?
parsed.mentions.forEach { mention ->
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 (recipientRepository.getRecipientSync(mention.second.toAddress()).isSelf) {
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
}
// quotes
if (isQuote) {
backgroundColor = null
// incoming quote gets accent-ish foreground, outgoing quote keeps default
foregroundColor = if (isOutgoingMessage) null else highlightedTextColor
}
// incoming message mentioning you
else if (mention.isSelf && !isOutgoingMessage) {
backgroundColor = context.getAccentColor()
foregroundColor = mainTextColor
}
// outgoing message
else if (isOutgoingMessage) {
backgroundColor = null
foregroundColor = mainTextColor
}
// incoming messages mentioning someone else
else {
backgroundColor = null
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
)
}
val start = mention.start
val end = mention.endExclusive

// apply the foreground, if any
foregroundColor?.let {
result.setSpan(
ForegroundColorSpan(it),
mention.first.lower,
mention.first.upper,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
backgroundColor?.let { background ->
result.setSpan(
RoundedBackgroundSpan(
context = context,
textColor = mainTextColor,
backgroundColor = background
),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}

// apply bold on the mention
foregroundColor?.let { fg ->
result.setSpan(
StyleSpan(Typeface.BOLD),
mention.first.lower,
mention.first.upper,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
ForegroundColorSpan(fg),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}

result.setSpan(
StyleSpan(Typeface.BOLD),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}

return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,6 @@ import androidx.core.text.toSpannable

object TextUtilities {

fun getIntrinsicHeight(text: CharSequence, paint: TextPaint, width: Int): Int {
val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0.0f, 1.0f)
.setIncludePad(false)
val layout = builder.build()
return layout.height
}

fun getIntrinsicLayout(text: CharSequence, paint: TextPaint, width: Int): StaticLayout {
val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0.0f, 1.0f)
.setIncludePad(false)
return builder.build()
}

fun TextView.getIntersectedModalSpans(event: MotionEvent): List<ModalURLSpan> {
val xInt = event.rawX.toInt()
val yInt = event.rawY.toInt()
Expand Down Expand Up @@ -59,17 +42,5 @@ object TextUtilities {

fun String.textSizeInBytes(): Int = this.toByteArray(Charsets.UTF_8).size

fun String.breakAt(vararg lengths: Int): String {
var cursor = 0
val out = StringBuilder()
for (len in lengths) {
val end = (cursor + len).coerceAtMost(length)
out.append(substring(cursor, end))
if (end < length) out.append('\n')
cursor = end
}
if (cursor < length) out.append('\n').append(substring(cursor))
return out.toString()
}

}
Loading