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:
parent
c98a8c30a9
commit
97bd7b7479
48 changed files with 1156 additions and 568 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue