Change (mention span) : rework and add more cases (#4476)

* change(mention span) : improve truncation logic

* change(mention span) : fix theme switching

* change(mention span) : start to pillify permalinks

* change(mention span) : use permalink directly

* change(mention span) : start improving mention type

* change(mention span) : use the appropriate MentionSpanProvider methods

* change(mention span) : introduce MentionSpanFormatter

* change(mention span) : introduce MentionSpanUpdater

* change(mention span) : Improve RoomNameCaches

* change(mention span) : remove useless param on HtmlConverterProvider

* change(mention span) : fix some remaining issues on the composer

* change(mention span) : remove pillifiedBody

* change(mention span) : fix some issues with pillification

* change(mention span) : fix getMentionsSpans

* change(mention span) : make sure all tests passes

* change(mention span) : remove the coroutine from the caches and a MentionSpanFormatterTest

* change(mention span) : add more tests on pillification

* change(mention span) : clean up

* Update screenshots

* change(mention span) : remove unexpected print

* change(mention span) : remove default values in constructor of TimelineTextBasedContent classes

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
ganfra 2025-03-28 11:20:32 +01:00 committed by GitHub
parent c98a8c30a9
commit 97bd7b7479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1156 additions and 568 deletions

View file

@ -106,6 +106,7 @@ fun TextComposer(
onReceiveSuggestion: (Suggestion?) -> Unit,
onSelectRichContent: ((Uri) -> Unit)?,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
resolveAtRoomMentionDisplay: () -> TextDisplay,
modifier: Modifier = Modifier,
showTextFormatting: Boolean = false,
subcomposing: Boolean = false,
@ -176,7 +177,7 @@ fun TextComposer(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
resolveRoomMentionDisplay = resolveAtRoomMentionDisplay,
onError = onError,
onTyping = onTyping,
onSelectRichContent = onSelectRichContent,
@ -930,6 +931,7 @@ private fun ATextComposer(
onTyping = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onSelectRichContent = null,
)
}

View file

@ -30,9 +30,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -75,7 +74,7 @@ fun MarkdownTextInput(
}
}
val mentionSpanTheme = LocalMentionSpanTheme.current
val mentionSpanUpdater = LocalMentionSpanUpdater.current
AndroidView(
modifier = Modifier
@ -124,10 +123,9 @@ fun MarkdownTextInput(
},
update = { editText ->
editText.applyStyleInCompose(richTextEditorStyle)
val text = state.text.value()
mentionSpanUpdater.updateMentionSpans(text)
if (state.text.needsDisplaying()) {
val text = state.text.value()
mentionSpanTheme.updateMentionStyles(text)
editText.updateEditableText(text)
if (canUpdateState) {
state.text.update(editText.editableText, false)

View file

@ -11,119 +11,153 @@ import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import android.text.style.ReplacementSpan
import androidx.core.text.getSpans
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A span that represents a mention (user, room, etc.) in text.
* @param type The type of mention this span represents.
*/
class MentionSpan(
text: String,
val rawValue: String,
val type: Type,
val type: MentionType,
) : ReplacementSpan() {
companion object {
private const val MAX_LENGTH = 20
}
private val backgroundPaint = Paint()
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
var backgroundColor: Int = 0
var textColor: Int = 0
var startPadding: Int = 0
var endPadding: Int = 0
var typeface: Typeface = Typeface.DEFAULT
private var backgroundColor: Int = 0
private var textColor: Int = 0
private var startPadding: Int = 0
private var endPadding: Int = 0
private var typeface: Typeface = Typeface.DEFAULT
private var textWidth = 0
private val backgroundPaint = Paint().apply {
isAntiAlias = true
color = backgroundColor
}
private var measuredTextWidth = 0
var text: String = text
set(value) {
field = value
mentionText = getActualText(text)
// The formatted display text, will be set by the formatter
var displayText: CharSequence = ""
private set
/**
* Updates the visual properties of this span.
*/
fun updateTheme(mentionSpanTheme: MentionSpanTheme) {
val isCurrentUser = when (type) {
is MentionType.User -> type.userId == mentionSpanTheme.currentUserId
else -> false
}
private var mentionText: CharSequence = getActualText(text)
fun update(mentionSpanTheme: MentionSpanTheme) {
val isCurrentUser = rawValue == mentionSpanTheme.currentUserId?.value
backgroundColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
Type.ROOM -> mentionSpanTheme.otherBackgroundColor
Type.EVERYONE -> mentionSpanTheme.currentUserBackgroundColor
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor
is MentionType.Room -> mentionSpanTheme.otherBackgroundColor
is MentionType.Message -> mentionSpanTheme.otherBackgroundColor
}
textColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
Type.ROOM -> mentionSpanTheme.otherTextColor
Type.EVERYONE -> mentionSpanTheme.currentUserTextColor
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor
is MentionType.Room -> mentionSpanTheme.otherTextColor
is MentionType.Message -> mentionSpanTheme.otherTextColor
}
backgroundPaint.color = backgroundColor
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
startPadding = startPaddingPx
endPadding = endPaddingPx
typeface = mentionSpanTheme.typeface.value
}
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
return textWidth + startPadding + endPadding
/**
* Updates the display text using a formatter.
*/
fun updateDisplayText(formatter: MentionSpanFormatter) {
displayText = formatter.formatDisplayText(type)
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
textPaint.set(paint)
textPaint.typeface = typeface
// Measure the full text width without truncation
measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt()
return measuredTextWidth + startPadding + endPadding
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
val availableWidth = (canvas.width - x).coerceAtLeast(0f)
val measuredWidth = measuredTextWidth + startPadding + endPadding
val pillWidth = minOf(availableWidth, measuredWidth.toFloat())
backgroundPaint.color = backgroundColor
val rect = RectF(x, top.toFloat(), x + pillWidth, y.toFloat() + extraVerticalSpace)
val radius = rect.height() / 2
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
paint.color = textColor
paint.typeface = typeface
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
}
private fun getActualText(text: String): CharSequence {
return buildString {
val mentionText = text.orEmpty()
when (type) {
Type.USER -> {
if (text.firstOrNull() != '@') {
append("@")
}
}
Type.ROOM -> {
if (text.firstOrNull() != '#') {
append("#")
}
}
Type.EVERYONE -> Unit
}
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
if (mentionText.length > MAX_LENGTH) {
append("")
}
textPaint.set(paint)
textPaint.color = textColor
textPaint.typeface = typeface
val availableWidthForText = availableWidth - startPadding - endPadding
val textToDraw = if (measuredTextWidth > availableWidthForText) {
TextUtils.ellipsize(
displayText,
textPaint,
availableWidthForText,
TextUtils.TruncateAt.END
)
} else {
displayText
}
}
enum class Type {
USER,
ROOM,
EVERYONE,
canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
}
}
fun CharSequence.getMentionSpans(): List<MentionSpan> {
/**
* Sealed interface representing different types of mentions.
*/
sealed interface MentionType {
data class User(val userId: UserId) : MentionType
data class Room(val roomIdOrAlias: RoomIdOrAlias) : MentionType
data class Message(val roomIdOrAlias: RoomIdOrAlias, val eventId: EventId) : MentionType
data object Everyone : MentionType
}
/**
* Extension function to get all MentionSpans from a CharSequence.
*/
fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List<MentionSpan> {
return if (this is android.text.Spanned) {
val customMentionSpans = getSpans<CustomMentionSpan>()
if (customMentionSpans.isNotEmpty()) {
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
customMentionSpans.map { it.providedSpan }.filterIsInstance<MentionSpan>()
} else {
// Otherwise try to get the spans directly
getSpans<MentionSpan>().toList()
}
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
val customMentionSpans = getSpans<CustomMentionSpan>(start, end)
.map { it.providedSpan }
.filterIsInstance<MentionSpan>()
// Collect all direct mention spans
val directMentionSpans = getSpans<MentionSpan>(start, end)
// Return the union of both
customMentionSpans + directMentionSpans
} else {
emptyList()
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import javax.inject.Inject
private const val EVERYONE_DISPLAY_TEXT = "@room"
private const val BUBBLE_ICON = "\uD83D\uDCAC" // 💬
interface MentionSpanFormatter {
fun formatDisplayText(mentionType: MentionType): CharSequence
}
/**
* Formatter for MentionSpan display text.
* This class is responsible for formatting the display text of a MentionSpan
* based on its MentionType and context.
*/
@ContributesBinding(RoomScope::class)
class DefaultMentionSpanFormatter @Inject constructor(
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
) : MentionSpanFormatter {
/**
* Format the display text for a mention span.
*
* @param mentionType The type of mention
* @return The formatted display text
*/
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> formatUserMention(mentionType.userId)
is MentionType.Room -> formatRoomMention(mentionType.roomIdOrAlias)
is MentionType.Message -> formatMessageMention(mentionType.roomIdOrAlias)
is MentionType.Everyone -> EVERYONE_DISPLAY_TEXT
}
}
private fun formatUserMention(userId: UserId): String {
// Try to get the display name from cache, fallback to userId
val displayName = roomMemberProfilesCache.getDisplayName(userId)
return if (displayName != null) {
"@$displayName"
} else {
userId.value
}
}
private fun formatRoomMention(roomIdOrAlias: RoomIdOrAlias): String {
val displayName = roomNamesCache.getDisplayName(roomIdOrAlias)
return if (displayName != null) {
"#$displayName"
} else {
roomIdOrAlias.identifier
}
}
private fun formatMessageMention(
roomIdOrAlias: RoomIdOrAlias,
): String {
val roomMention = formatRoomMention(roomIdOrAlias)
return "$BUBBLE_ICON > $roomMention"
}
}

View file

@ -7,46 +7,118 @@
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import javax.inject.Inject
@Stable
private const val EVERYONE_MENTION_TEXT = "@room"
/**
* Provider for [MentionSpan]s.
*/
open class MentionSpanProvider @Inject constructor(
private val permalinkParser: PermalinkParser,
private val mentionSpanFormatter: MentionSpanFormatter,
private val mentionSpanTheme: MentionSpanTheme,
) {
fun getMentionSpanFor(text: String, url: String): MentionSpan {
/**
* Creates a mention span from a text and URL.
*
* @param text The text associated with the mention
* @param url The URL associated with the mention
* @return A mention span if the URL can be parsed as a permalink, null otherwise
*/
fun getMentionSpanFor(text: String, url: String): MentionSpan? {
val permalinkData = permalinkParser.parse(url)
return when {
permalinkData is PermalinkData.UserLink -> {
MentionSpan(
text = text,
rawValue = permalinkData.userId.toString(),
type = MentionSpan.Type.USER,
)
return getMentionSpanFor(text, permalinkData)
}
/**
* Creates a mention span from a text and permalink data.
*
* @param text The text associated with the mention
* @param permalinkData The permalink data associated with the mention
* @return A mention span based on the permalink data, null if the permalink data is not supported
*/
private fun getMentionSpanFor(text: String, permalinkData: PermalinkData): MentionSpan? {
return when (permalinkData) {
is PermalinkData.UserLink -> {
createUserMentionSpan(permalinkData.userId)
}
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
MentionSpan(
text = text,
rawValue = "@room",
type = MentionSpan.Type.EVERYONE,
)
is PermalinkData.RoomLink -> {
val eventId = permalinkData.eventId
if (eventId != null) {
createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId)
} else {
createRoomMentionSpan(permalinkData.roomIdOrAlias)
}
}
permalinkData is PermalinkData.RoomLink -> {
MentionSpan(
text = text,
rawValue = permalinkData.roomIdOrAlias.identifier,
type = MentionSpan.Type.ROOM,
)
}
else -> {
MentionSpan(
text = text,
rawValue = text,
type = MentionSpan.Type.ROOM,
)
is PermalinkData.FallbackLink -> {
if (text == EVERYONE_MENTION_TEXT) {
createEveryoneMentionSpan()
} else {
null
}
}
else -> null
}
}
/**
* Create a mention span for a user mention.
*
* @param userId The user ID
* @return A mention span for the user
*/
fun createUserMentionSpan(userId: UserId): MentionSpan {
return MentionSpan(type = MentionType.User(userId = userId)).apply {
updateDisplayText(mentionSpanFormatter)
updateTheme(mentionSpanTheme)
}
}
/**
* Create a mention span for a room mention.
*
* @param roomIdOrAlias The room ID or alias
* @return A mention span for the room
*/
fun createRoomMentionSpan(roomIdOrAlias: RoomIdOrAlias): MentionSpan {
return MentionSpan(MentionType.Room(roomIdOrAlias)).apply {
updateDisplayText(mentionSpanFormatter)
updateTheme(mentionSpanTheme)
}
}
/**
* Create a mention span for a message mention.
*
* @param roomIdOrAlias The room ID or alias where the message is located
* @param eventId The event ID of the message
* @return A mention span for the message
*/
fun createMessageMentionSpan(
roomIdOrAlias: RoomIdOrAlias,
eventId: EventId,
): MentionSpan {
return MentionSpan(type = MentionType.Message(roomIdOrAlias, eventId)).apply {
updateTheme(mentionSpanTheme)
updateDisplayText(mentionSpanFormatter)
}
}
/**
* Create a mention span for @room (everyone).
*
* @return A mention span for @room
*/
fun createEveryoneMentionSpan(): MentionSpan {
return MentionSpan(type = MentionType.Everyone).apply {
updateTheme(mentionSpanTheme)
updateDisplayText(mentionSpanFormatter)
}
}
}

View file

@ -15,11 +15,9 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@ -34,6 +32,9 @@ import io.element.android.libraries.designsystem.theme.currentUserMentionPillBac
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
@ -45,13 +46,14 @@ import javax.inject.Inject
/**
* Theme used for mention spans.
* To make this work, you need to:
* 1. Provide [LocalMentionSpanTheme] in a composable that wraps the ones where you want to use mentions.
* 2. Call [MentionSpanTheme.updateStyles] with the current [UserId] so the colors and sizes are computed.
* 3. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.update] to update the styles of the mention spans.
* 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed.
* 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans.
*/
@Stable
class MentionSpanTheme @Inject constructor() {
internal var currentUserId: UserId? = null
@SingleIn(SessionScope::class)
class MentionSpanTheme(val currentUserId: UserId) {
@Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0
@ -66,8 +68,7 @@ class MentionSpanTheme @Inject constructor() {
*/
@Suppress("ComposableNaming")
@Composable
fun updateStyles(currentUserId: UserId) {
this.currentUserId = currentUserId
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
@ -93,24 +94,28 @@ fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) {
val spanned = charSequence as? Spanned ?: return
val mentionSpans = spanned.getMentionSpans()
for (span in mentionSpans) {
span.update(this)
span.updateTheme(this)
}
}
/**
* Composition local containing the current [MentionSpanTheme].
*/
val LocalMentionSpanTheme = staticCompositionLocalOf {
MentionSpanTheme()
}
@PreviewsDayNight
@Composable
internal fun MentionSpanThemePreview() {
@PreviewsDayNight
@Composable
internal fun MentionSpanThemePreview() {
ElementPreview {
val mentionSpanTheme = remember { MentionSpanTheme() }
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
val provider = remember {
MentionSpanProvider(
mentionSpanTheme = mentionSpanTheme,
mentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> mentionType.userId.value
is MentionType.Room -> mentionType.roomIdOrAlias.identifier
is MentionType.Message -> "\uD83D\uDCAC > ${mentionType.roomIdOrAlias.identifier}"
is MentionType.Everyone -> "@room"
}
}
},
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
@ -133,36 +138,31 @@ val LocalMentionSpanTheme = staticCompositionLocalOf {
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org")
fun mentionSpanEveryone() = provider.getMentionSpanFor("@room", "@room")
mentionSpanTheme.updateStyles(currentUserId = UserId("@me:matrix.org"))
fun mentionSpanEveryone() = provider.createEveryoneMentionSpan()
mentionSpanTheme.updateStyles()
CompositionLocalProvider(
LocalMentionSpanTheme provides mentionSpanTheme
) {
AndroidView(factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("This is a ")
append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ")
append("@mention", mentionSpanOther(), 0)
append(" to other user. This is for everyone in the ")
append("@room", mentionSpanEveryone(), 0)
append(". This one is for a link to another room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
}
mentionSpanTheme.updateMentionStyles(text)
setTextColor(textColor)
AndroidView(factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("This is a ")
append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ")
append("@mention", mentionSpanOther(), 0)
append(" to other user. This is for everyone in the ")
append("@room", mentionSpanEveryone(), 0)
append(". This one is for a link to another room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
}
})
}
setTextColor(textColor)
}
})
}
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import javax.inject.Inject
interface MentionSpanUpdater {
fun updateMentionSpans(text: CharSequence): CharSequence
@Composable
fun rememberMentionSpans(text: CharSequence): CharSequence
}
@ContributesBinding(RoomScope::class)
class DefaultMentionSpanUpdater @Inject constructor(
private val formatter: MentionSpanFormatter,
private val theme: MentionSpanTheme,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
) : MentionSpanUpdater {
@Composable
override fun rememberMentionSpans(text: CharSequence): CharSequence {
val isLightTheme = ElementTheme.isLightTheme
val roomInfoCacheUpdate by roomNamesCache.updateFlow.collectAsState(0)
val roomMemberProfilesCacheUpdate by roomMemberProfilesCache.updateFlow.collectAsState(0)
return remember(text, roomInfoCacheUpdate, roomMemberProfilesCacheUpdate, isLightTheme) {
updateMentionSpans(text)
text
}
}
override fun updateMentionSpans(text: CharSequence): CharSequence {
for (mentionSpan in text.getMentionSpans()) {
mentionSpan.updateTheme(theme)
mentionSpan.updateDisplayText(formatter)
}
return text
}
}
private object NoOpMentionSpanUpdater : MentionSpanUpdater {
override fun updateMentionSpans(text: CharSequence): CharSequence {
return text
}
@Composable
override fun rememberMentionSpans(text: CharSequence): CharSequence {
return text
}
}
val LocalMentionSpanUpdater = staticCompositionLocalOf<MentionSpanUpdater> { NoOpMentionSpanUpdater }

View file

@ -21,14 +21,13 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.text.getSpans
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import kotlinx.parcelize.Parcelize
@ -48,39 +47,33 @@ class MarkdownTextEditorState(
fun insertSuggestion(
resolvedSuggestion: ResolvedSuggestion,
mentionSpanProvider: MentionSpanProvider,
permalinkBuilder: PermalinkBuilder,
) {
val suggestion = currentSuggestion ?: return
when (resolvedSuggestion) {
is ResolvedSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room"
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value())
val text = resolvedSuggestion.roomAlias.value
val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
@ -98,19 +91,23 @@ class MarkdownTextEditorState(
val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention)
when (mention.type) {
MentionSpan.Type.USER -> {
permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link ->
replace(start, end, "[${mention.rawValue}]($link)")
is MentionType.User -> {
permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link ->
replace(start, end, "[${mention.type.userId}]($link)")
}
}
MentionSpan.Type.EVERYONE -> {
is MentionType.Everyone -> {
replace(start, end, "@room")
}
MentionSpan.Type.ROOM -> {
permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link ->
replace(start, end, "[${mention.text}]($link)")
is MentionType.Room -> {
val roomIdOrAlias = mention.type.roomIdOrAlias
if (roomIdOrAlias is RoomIdOrAlias.Alias) {
permalinkBuilder.permalinkForRoomAlias(roomIdOrAlias.roomAlias).getOrNull()?.let { link ->
replace(start, end, "[${roomIdOrAlias.roomAlias}]($link)")
}
}
}
else -> Unit
}
}
}
@ -122,12 +119,12 @@ class MarkdownTextEditorState(
fun getMentions(): List<IntentionalMention> {
val text = SpannableString(text.value())
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
val mentionSpans = text.getMentionSpans()
return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) {
MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue))
MentionSpan.Type.EVERYONE -> IntentionalMention.Room
MentionSpan.Type.ROOM -> null
is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId)
is MentionType.Everyone -> IntentionalMention.Room
else -> null
}
}
}

View file

@ -16,14 +16,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
@ -146,7 +145,6 @@ class MarkdownTextInputTest {
@Test
fun `inserting a mention replaces the existing text with a span`() = runTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state)
@ -155,8 +153,7 @@ class MarkdownTextInputTest {
editor = it.findEditor()
state.insertSuggestion(
ResolvedSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(permalinkParser = permalinkParser),
permalinkBuilder,
aMentionSpanProvider(permalinkParser),
)
}
rule.awaitIdle()

View file

@ -14,8 +14,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.tests.testutils.WarmUpRule
import org.junit.Rule
import org.junit.Test
@ -28,22 +27,22 @@ class IntentionalMentionSpanProviderTest {
val warmUpRule = WarmUpRule()
private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider(
permalinkParser = permalinkParser,
)
private val mentionSpanProvider = aMentionSpanProvider(permalinkParser)
@Test
fun `getting mention span for a user returns a MentionSpan of type USER`() {
permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}")
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.USER)
assertThat(mentionSpan?.type).isInstanceOf(MentionType.User::class.java)
val userType = mentionSpan?.type as MentionType.User
assertThat(userType.userId).isEqualTo(A_USER_ID)
}
@Test
fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE)
assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone)
}
@Test
@ -54,6 +53,8 @@ class IntentionalMentionSpanProviderTest {
)
)
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.ROOM)
assertThat(mentionSpan?.type).isInstanceOf(MentionType.Room::class.java)
val roomType = mentionSpan?.type as MentionType.Room
assertThat(roomType.roomIdOrAlias).isEqualTo(RoomAlias("#room:matrix.org").toRoomIdOrAlias())
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.impl.mentions
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionType
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MentionSpanFormatterTest {
private val roomMemberProfilesCache = RoomMemberProfilesCache()
private val roomNamesCache = RoomNamesCache()
private val formatter = DefaultMentionSpanFormatter(
roomMemberProfilesCache = roomMemberProfilesCache,
roomNamesCache = roomNamesCache,
)
@Test
fun `formatDisplayText - formats user mention with empty cache`() = runTest {
val userId = A_USER_ID
val mentionType = MentionType.User(userId)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(userId.value)
}
@Test
fun `formatDisplayText - formats user mention with filled cache`() = runTest {
val userId = A_USER_ID
val roomMember = aRoomMember(userId, displayName = "alice")
roomMemberProfilesCache.replace(listOf(roomMember))
val mentionType = MentionType.User(userId)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("@alice")
}
@Test
fun `formatDisplayText - formats room mention with empty cache`() = runTest {
val roomAlias = A_ROOM_ALIAS
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(roomAlias.value)
}
@Test
fun `formatDisplayText - formats room mention with filled cache`() = runTest {
val roomAlias = A_ROOM_ALIAS
val roomSummary = aRoomSummary(
canonicalAlias = roomAlias,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("#my room")
}
@Test
fun `formatDisplayText - formats room mention with room id and empty cache`() = runTest {
val roomId = A_ROOM_ID
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(roomId.value)
}
@Test
fun `formatDisplayText - formats room mention with room id and filled cache`() = runTest {
val roomId = A_ROOM_ID
val roomSummary = aRoomSummary(
roomId = roomId,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("#my room")
}
@Test
fun `formatDisplayText - formats message mention with empty cache`() = runTest {
val roomId = A_ROOM_ID
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("💬 > ${roomId.value}")
}
@Test
fun `formatDisplayText - formats message mention with filled cache`() = runTest {
val roomId = A_ROOM_ID
val roomSummary = aRoomSummary(
roomId = roomId,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("💬 > #my room")
}
@Test
fun `formatDisplayText - formats everyone mention`() = runTest {
val mentionType = MentionType.Everyone
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("@room")
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.impl.mentions
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionType
fun aMentionSpanProvider(
permalinkParser: PermalinkParser = FakePermalinkParser(),
mentionSpanFormatter: MentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return mentionType.toString()
}
},
mentionSpanTheme: MentionSpanTheme = MentionSpanTheme(A_USER_ID),
): MentionSpanProvider {
return MentionSpanProvider(
permalinkParser = permalinkParser,
mentionSpanFormatter = mentionSpanFormatter,
mentionSpanTheme = mentionSpanTheme,
)
}

View file

@ -12,6 +12,8 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.IntentionalMention
@ -20,8 +22,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -35,9 +38,8 @@ class MarkdownTextEditorStateTest {
fun `insertMention - room alias - getMentions return empty list`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val suggestion = aRoomAliasSuggestion()
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
}
@ -48,9 +50,8 @@ class MarkdownTextEditorStateTest {
}
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(suggestion, mentionSpanProvider)
}
@Test
@ -60,9 +61,8 @@ class MarkdownTextEditorStateTest {
}
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(suggestion, mentionSpanProvider)
}
@Test
@ -70,31 +70,11 @@ class MarkdownTextEditorStateTest {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member)
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertSuggestion - with member but failed PermalinkBuilder result`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isEmpty()
}
@Test
fun `insertSuggestion - with member`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
@ -103,10 +83,9 @@ class MarkdownTextEditorStateTest {
val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
@ -119,11 +98,10 @@ class MarkdownTextEditorStateTest {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val mention = ResolvedSuggestion.AtRoom
val permalinkBuilder = FakePermalinkBuilder()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
@ -177,16 +155,10 @@ class MarkdownTextEditorStateTest {
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
}
private fun aMentionSpanProvider(
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider {
return MentionSpanProvider(permalinkParser)
}
private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER)
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE)
val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM)
val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org")))
val atRoomMentionSpan = MentionSpan(MentionType.Everyone)
val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias()))
return buildSpannedString {
append("Hello ")
inSpans(userMentionSpan) {