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 a962479788
commit 042c0c5a6b
48 changed files with 1156 additions and 568 deletions

View file

@ -241,7 +241,7 @@ class IntentResolverTest {
} }
private fun createIntentResolver( private fun createIntentResolver(
permalinkParserResult: () -> PermalinkData = { lambdaError() } permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
): IntentResolver { ): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = DeeplinkParser(), deeplinkParser = DeeplinkParser(),

View file

@ -8,12 +8,11 @@
package io.element.android.features.messages.api.timeline package io.element.android.features.messages.api.timeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.HtmlConverter
interface HtmlConverterProvider { interface HtmlConverterProvider {
@Composable @Composable
fun Update(currentUserId: UserId) fun Update()
fun provide(): HtmlConverter fun provide(): HtmlConverter
} }

View file

@ -57,6 +57,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
@ -73,17 +74,19 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -100,11 +103,14 @@ class MessagesFlowNode @AssistedInject constructor(
private val locationService: LocationService, private val locationService: LocationService,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
private val mentionSpanUpdater: MentionSpanUpdater,
private val mentionSpanTheme: MentionSpanTheme, private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController, private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
) : BaseFlowNode<MessagesFlowNode.NavTarget>( ) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(), initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@ -172,13 +178,29 @@ class MessagesFlowNode @AssistedInject constructor(
timelineController.close() timelineController.close()
} }
) )
setupCacheUpdaters()
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
}
private fun setupCacheUpdaters() {
room.membersStateFlow room.membersStateFlow
.onEach { membersState -> .onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) withContext(coroutineDispatchers.computation) {
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
}
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
pinnedEventsTimelineProvider.launchIn(lifecycleScope) matrixClient.roomListService
.allRooms
.summaries
.onEach {
withContext(coroutineDispatchers.computation) {
roomNamesCache.replace(it)
}
}
.launchIn(lifecycleScope)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -465,10 +487,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles(currentUserId = room.sessionId) mentionSpanTheme.updateStyles()
CompositionLocalProvider( CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache, LocalMentionSpanUpdater provides mentionSpanUpdater
LocalMentionSpanTheme provides mentionSpanTheme,
) { ) {
BackstackWithOverlayBox(modifier) BackstackWithOverlayBox(modifier)
} }

View file

@ -128,7 +128,7 @@ class MessagesPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): MessagesState { override fun present(): MessagesState {
htmlConverterProvider.Update(currentUserId = room.sessionId) htmlConverterProvider.Update()
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()

View file

@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions(
onDeleteVoiceMessage = {}, onDeleteVoiceMessage = {},
onReceiveSuggestion = {}, onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onError = {}, onError = {},
onTyping = {}, onTyping = {},
onSelectRichContent = {}, onSelectRichContent = {},

View file

@ -47,7 +47,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -56,7 +55,6 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -65,7 +63,6 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@ -119,7 +116,6 @@ class MessageComposerPresenter @AssistedInject constructor(
private val draftService: ComposerDraftService, private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper, private val pillificationHelper: TextPillificationHelper,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor, private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
@ -331,7 +327,6 @@ class MessageComposerPresenter @AssistedInject constructor(
markdownTextEditorState.insertSuggestion( markdownTextEditorState.insertSuggestion(
resolvedSuggestion = event.resolvedSuggestion, resolvedSuggestion = event.resolvedSuggestion,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder,
) )
suggestionSearchTrigger.value = null suggestionSearchTrigger.value = null
} }
@ -344,23 +339,24 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
} }
val mentionSpanTheme = LocalMentionSpanTheme.current val resolveMentionDisplay = remember {
val resolveMentionDisplay = remember(mentionSpanTheme) {
{ text: String, url: String -> { text: String, url: String ->
val permalinkData = permalinkParser.parse(url) val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
if (permalinkData is PermalinkData.UserLink) { if (mentionSpan != null) {
val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)
mentionSpan.update(mentionSpanTheme)
TextDisplay.Custom(mentionSpan) TextDisplay.Custom(mentionSpan)
} else { } else {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) TextDisplay.Plain
mentionSpan.update(mentionSpanTheme)
TextDisplay.Custom(mentionSpan)
} }
} }
} }
val resolveAtRoomMentionDisplay = remember {
{
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
TextDisplay.Custom(mentionSpan)
}
}
return MessageComposerState( return MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
@ -371,6 +367,7 @@ class MessageComposerPresenter @AssistedInject constructor(
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
suggestions = suggestions.toPersistentList(), suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )
} }
@ -640,8 +637,8 @@ class MessageComposerPresenter @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else { } else {
val markdown = richTextEditorState.messageMarkdown val markdown = richTextEditorState.messageMarkdown
val pilliefiedMarkdown = pillificationHelper.pillify(markdown) val markdownWithMentions = pillificationHelper.pillify(markdown, false)
markdownTextEditorState.text.update(pilliefiedMarkdown, true) markdownTextEditorState.text.update(markdownWithMentions, true)
// Give some time for the focus of the previous editor to be cleared // Give some time for the focus of the previous editor to be cleared
delay(100) delay(100)
markdownTextEditorState.requestFocusAction() markdownTextEditorState.requestFocusAction()
@ -709,7 +706,7 @@ class MessageComposerPresenter @AssistedInject constructor(
if (content.isEmpty()) { if (content.isEmpty()) {
markdownTextEditorState.selection = IntRange.EMPTY markdownTextEditorState.selection = IntRange.EMPTY
} }
val pillifiedContent = pillificationHelper.pillify(content) val pillifiedContent = pillificationHelper.pillify(content, false)
markdownTextEditorState.text.update(pillifiedContent, true) markdownTextEditorState.text.update(pillifiedContent, true)
if (requestFocus) { if (requestFocus) {
markdownTextEditorState.requestFocusAction() markdownTextEditorState.requestFocusAction()

View file

@ -25,5 +25,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val suggestions: ImmutableList<ResolvedSuggestion>, val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay, val resolveMentionDisplay: (String, String) -> TextDisplay,
val resolveAtRoomMentionDisplay: () -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) )

View file

@ -43,5 +43,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
suggestions = suggestions, suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -113,6 +113,7 @@ internal fun MessageComposerView(
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived, onReceiveSuggestion = ::onSuggestionReceived,
resolveMentionDisplay = state.resolveMentionDisplay, resolveMentionDisplay = state.resolveMentionDisplay,
resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
onError = ::onError, onError = ::onError,
onTyping = ::onTyping, onTyping = ::onTyping,
onSelectRichContent = ::sendUri, onSelectRichContent = ::sendUri,

View file

@ -16,11 +16,9 @@ import androidx.compose.ui.platform.LocalInspectionMode
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.display.MentionDisplayHandler
@ -29,42 +27,45 @@ import io.element.android.wysiwyg.utils.HtmlConverter
import uniffi.wysiwyg_composer.newMentionDetector import uniffi.wysiwyg_composer.newMentionDetector
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(RoomScope::class)
@SingleIn(SessionScope::class) @SingleIn(RoomScope::class)
class DefaultHtmlConverterProvider @Inject constructor( class DefaultHtmlConverterProvider @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
) : HtmlConverterProvider { ) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
override fun Update(currentUserId: UserId) { override fun Update() {
val isInEditMode = LocalInspectionMode.current val isInEditMode = LocalInspectionMode.current
val mentionDetector = remember(isInEditMode) { val mentionDetector = remember(isInEditMode) {
if (isInEditMode) null else newMentionDetector() if (isInEditMode) null else newMentionDetector()
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanTheme) { htmlConverter.value = remember(editorStyle) {
StyledHtmlConverter( StyledHtmlConverter(
context = context, context = context,
mentionDisplayHandler = object : MentionDisplayHandler { mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay { override fun resolveAtRoomMentionDisplay(): TextDisplay {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#") val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
mentionSpan.update(mentionSpanTheme)
return TextDisplay.Custom(mentionSpan) return TextDisplay.Custom(mentionSpan)
} }
override fun resolveMentionDisplay(text: String, url: String): TextDisplay { override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
mentionSpan.update(mentionSpanTheme) return if (mentionSpan != null) {
return TextDisplay.Custom(mentionSpan) TextDisplay.Custom(mentionSpan)
} else {
TextDisplay.Plain
}
} }
}, },
isEditor = false, isEditor = false,
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() } isMention = { _, url ->
mentionDetector?.isMention(url).orFalse()
}
).apply { ).apply {
configureWith(editorStyle) configureWith(editorStyle)
} }

View file

@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview(
event = event.copy( event = event.copy(
content = event.content.copy( content = event.content.copy(
body = str, body = str,
pillifiedBody = str, formattedBody = str,
), ),
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
), ),

View file

@ -14,9 +14,6 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@ -32,14 +29,8 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle 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.getMentionSpans
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -51,7 +42,7 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) { ) {
val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) && val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis() content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when { val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
@ -61,10 +52,10 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary, LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides textStyle LocalTextStyle provides textStyle
) { ) {
val body = getTextWithResolvedMentions(content) val text = getTextWithResolvedMentions(content)
Box(modifier.semantics { contentDescription = content.plainText }) { Box(modifier.semantics { contentDescription = content.plainText }) {
EditorStyledText( EditorStyledText(
text = body, text = text,
onLinkClickedListener = onLinkClick, onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick, onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(), style = ElementRichTextEditorStyle.textStyle(),
@ -78,36 +69,9 @@ fun TimelineItemTextView(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable @Composable
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence { internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current val mentionSpanUpdater = LocalMentionSpanUpdater.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState() val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
val mentionSpanTheme = LocalMentionSpanTheme.current return SpannableString(bodyWithResolvedMentions)
val formattedBody = content.formattedBody ?: content.pillifiedBody
val textWithMentions = remember(formattedBody, mentionSpanTheme, lastCacheUpdate) {
updateMentionSpans(formattedBody, userProfileCache)
mentionSpanTheme.updateMentionStyles(formattedBody)
formattedBody
}
return SpannableString(textWithMentions)
}
private fun updateMentionSpans(text: CharSequence, cache: RoomMemberProfilesCache): Boolean {
var changedContents = false
for (mentionSpan in text.getMentionSpans()) {
when (mentionSpan.type) {
MentionSpan.Type.USER -> {
val displayName = cache.getDisplayName(UserId(mentionSpan.rawValue)) ?: mentionSpan.rawValue
if (mentionSpan.text != displayName) {
changedContents = true
mentionSpan.text = displayName
}
}
// There's no need to do anything for `@room` pills
MentionSpan.Type.EVERYONE -> Unit
// Nothing yet for room mentions
MentionSpan.Type.ROOM -> Unit
}
}
return changedContents
} }
@PreviewsDayNight @PreviewsDayNight
@ -126,7 +90,7 @@ internal fun TimelineItemTextViewPreview(
@Composable @Composable
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview { internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
val content = aTimelineItemTextContent( val content = aTimelineItemTextContent(
pillifiedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.") formattedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.")
) )
TimelineItemTextView( TimelineItemTextView(
content = content, content = content,
@ -139,7 +103,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
@Composable @Composable
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview { internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
val content = aTimelineItemTextContent( val content = aTimelineItemTextContent(
pillifiedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!") formattedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!")
) )
TimelineItemTextView( TimelineItemTextView(
content = content, content = content,

View file

@ -69,13 +69,16 @@ class TimelineItemContentMessageFactory @Inject constructor(
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
emoteBody
).safeLinkify()
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument( htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName", prefix = "* $senderDisambiguatedDisplayName",
), ),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(), formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
@ -123,10 +126,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null, htmlDocument = null,
plainText = body, formattedBody = body,
formattedBody = null,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} else { } else {
@ -219,20 +220,26 @@ class TimelineItemContentMessageFactory @Inject constructor(
} }
is NoticeMessageType -> { is NoticeMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), htmlDocument = htmlDocument,
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
is TextMessageType -> { is TextMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
pillifiedBody = textPillificationHelper.pillify(body).safeLinkify(),
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
@ -240,9 +247,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null, htmlDocument = null,
formattedBody = body.withLinks(), formattedBody = textPillificationHelper.pillify(body).safeLinkify(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
@ -263,6 +269,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide() val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body.trimEnd()) .fromHtmlToSpans(formattedBody.body.trimEnd())
.let { textPillificationHelper.pillify(it) }
.safeLinkify() .safeLinkify()
return if (prefix != null) { return if (prefix != null) {
buildSpannedString { buildSpannedString {

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent( data class TimelineItemEmoteContent(
override val body: String, override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val formattedBody: CharSequence,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent" override val type: String = "TimelineItemEmoteContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
} }

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import org.jsoup.nodes.Document
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> { class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf( override val values = sequenceOf(
@ -58,35 +59,46 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineIt
) )
} }
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent( fun aTimelineItemEmoteContent(
body = "Emote", body: String = "Emote",
htmlDocument = null, htmlDocument: Document? = null,
formattedBody = null, formattedBody: CharSequence = body,
isEdited = false, isEdited: Boolean = false,
) = TimelineItemEmoteContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
) )
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent( fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown data = UnableToDecryptContent.Data.Unknown
) )
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent( fun aTimelineItemNoticeContent(
body = "Notice", body: String = "Notice",
htmlDocument = null, htmlDocument: Document? = null,
formattedBody = null, formattedBody: CharSequence = body,
isEdited = false, isEdited: Boolean = false,
) = TimelineItemNoticeContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
) )
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
fun aTimelineItemTextContent( fun aTimelineItemTextContent(
body: String = "Text", body: String = "Text",
pillifiedBody: CharSequence = body, htmlDocument: Document? = null,
formattedBody: CharSequence = body,
isEdited: Boolean = false,
) = TimelineItemTextContent( ) = TimelineItemTextContent(
body = body, body = body,
pillifiedBody = pillifiedBody, htmlDocument = htmlDocument,
htmlDocument = null, formattedBody = formattedBody,
formattedBody = null, isEdited = isEdited,
isEdited = false,
) )
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent fun aTimelineItemUnknownContent() = TimelineItemUnknownContent

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent( data class TimelineItemNoticeContent(
override val body: String, override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val formattedBody: CharSequence,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent" override val type: String = "TimelineItemNoticeContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
} }

View file

@ -20,14 +20,12 @@ sealed interface TimelineItemTextBasedContent :
/** The raw body of the event, in Markdown format. */ /** The raw body of the event, in Markdown format. */
val body: String val body: String
/** The body of the event, with mentions replaced by their pillified version. */
val pillifiedBody: CharSequence
/** The parsed HTML DOM of the formatted event body. */ /** The parsed HTML DOM of the formatted event body. */
val htmlDocument: Document? val htmlDocument: Document?
/** The formatted body of the event, already parsed and with the DOM translated to Android spans. */ /** The formatted body of the event, already parsed and with the DOM translated to Android spans.
val formattedBody: CharSequence? * This can also includes mention spans from permalink parsing */
val formattedBody: CharSequence
/** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */ /** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
val plainText: String val plainText: String

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent( data class TimelineItemTextContent(
override val body: String, override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val formattedBody: CharSequence,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent" override val type: String = "TimelineItemTextContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
} }

View file

@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.utils
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan
import android.util.Patterns
import androidx.core.text.getSpans import androidx.core.text.getSpans
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -16,63 +19,90 @@ import io.element.android.libraries.matrix.api.core.MatrixPatternType
import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias 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.UserId
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.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.view.spans.CodeBlockSpan
import io.element.android.wysiwyg.view.spans.InlineCodeSpan
import javax.inject.Inject import javax.inject.Inject
interface TextPillificationHelper { interface TextPillificationHelper {
fun pillify(text: CharSequence): CharSequence fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence
} }
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultTextPillificationHelper @Inject constructor( class DefaultTextPillificationHelper @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
private val permalinkBuilder: PermalinkBuilder,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val permalinkBuilder: PermalinkBuilder,
) : TextPillificationHelper { ) : TextPillificationHelper {
@Suppress("LoopWithTooManyJumpStatements") @Suppress("LoopWithTooManyJumpStatements")
override fun pillify(text: CharSequence): CharSequence { override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end } return SpannableStringBuilder(text).apply {
if (matches.isEmpty()) return text pillifyMatrixPatterns(this)
if (pillifyPermalinks) {
pillifyPermalinks(this)
}
}
}
val spannable = SpannableStringBuilder(text) private fun pillifyMatrixPatterns(text: SpannableStringBuilder) {
val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
if (matches.isEmpty()) return
for (match in matches) { for (match in matches) {
if (!text.canPillify(match.start, match.end)) continue
when (match.type) { when (match.type) {
MatrixPatternType.USER_ID -> { MatrixPatternType.USER_ID -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty() val userId = UserId(match.value)
if (!mentionSpanExists) { val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
val userId = UserId(match.value) text.replace(match.start, match.end, "@ ")
val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink) permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink ->
roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it } // Also add a URLSpan in case of raw user id so it can be clicked
spannable.replace(match.start, match.end, "@ ") val urlSpan = URLSpan(permalink)
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
} }
MatrixPatternType.ROOM_ALIAS -> { MatrixPatternType.ROOM_ALIAS -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty() val roomAlias = RoomAlias(match.value)
if (!mentionSpanExists) { val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue text.replace(match.start, match.end, "@ ")
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink) text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.replace(match.start, match.end, "@ ") permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink ->
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // Also add a URLSpan in case of raw room alias so it can be clicked
val urlSpan = URLSpan(permalink)
text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
} }
MatrixPatternType.AT_ROOM -> { MatrixPatternType.AT_ROOM -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty() val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
if (!mentionSpanExists) { text.replace(match.start, match.end, "@ ")
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "") text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.replace(match.start, match.end, "@ ")
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} }
else -> Unit else -> Unit
} }
} }
return spannable }
private fun pillifyPermalinks(text: SpannableStringBuilder) {
for (match in Patterns.WEB_URL.toRegex().findAll(text)) {
val start = match.range.first
val end = match.range.last + 1
if (!text.canPillify(start, end)) continue
val url = text.substring(match.range)
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, url)
if (mentionSpan != null) {
text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
private fun Spanned.canPillify(start: Int, end: Int): Boolean {
if (getMentionSpans(start, end).isNotEmpty()) return false
if (getSpans<CodeBlockSpan>(start, end).isNotEmpty()) return false
if (getSpans<InlineCodeSpan>(start, end).isNotEmpty()) return false
return true
} }
} }

View file

@ -153,7 +153,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false, isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -201,7 +201,7 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
isEditable = false, isEditable = false,
isThreaded = true, isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -248,7 +248,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false, isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -296,7 +296,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false, isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -344,7 +344,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false, isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -391,7 +391,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -439,7 +439,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
isThreaded = true, isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -486,7 +486,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -804,7 +804,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -852,7 +852,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -907,7 +907,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -956,7 +956,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
val redactedEvent = aMessageEvent( val redactedEvent = aMessageEvent(
isMine = true, isMine = true,
@ -1006,7 +1006,7 @@ class ActionListPresenterTest {
eventId = null, eventId = null,
isMine = true, isMine = true,
canBeRepliedTo = false, canBeRepliedTo = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(

View file

@ -38,7 +38,7 @@ internal fun aMessageEvent(
isMine: Boolean = true, isMine: Boolean = true,
isEditable: Boolean = true, isEditable: Boolean = true,
canBeRepliedTo: Boolean = true, canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
@ -67,7 +68,6 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
@ -82,6 +82,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -1535,8 +1536,11 @@ class MessageComposerPresenterTest {
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser), mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), permalinkParser = permalinkParser,
mentionSpanFormatter = FakeMentionSpanFormatter(),
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
),
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true, isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
@ -1562,7 +1566,6 @@ class MessageComposerPresenterTest {
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
roomMemberProfilesCache = roomMemberProfilesCache,
suggestionsProcessor = SuggestionsProcessor(), suggestionsProcessor = SuggestionsProcessor(),
).apply { ).apply {
isTesting = true isTesting = true

View file

@ -11,9 +11,11 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -23,10 +25,16 @@ import org.robolectric.RobolectricTestRunner
class DefaultHtmlConverterProviderTest { class DefaultHtmlConverterProviderTest {
@get:Rule val composeTestRule = createComposeRule() @get:Rule val composeTestRule = createComposeRule()
private val provider = DefaultHtmlConverterProvider(
mentionSpanProvider = MentionSpanProvider(
permalinkParser = FakePermalinkParser(),
mentionSpanFormatter = FakeMentionSpanFormatter(),
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
)
)
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
val exception = runCatching { provider.provide() }.exceptionOrNull() val exception = runCatching { provider.provide() }.exceptionOrNull()
assertThat(exception).isInstanceOf(IllegalStateException::class.java) assertThat(exception).isInstanceOf(IllegalStateException::class.java)
@ -34,13 +42,11 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID) provider.Update()
} }
} }
val htmlConverter = runCatching { provider.provide() }.getOrNull() val htmlConverter = runCatching { provider.provide() }.getOrNull()
assertThat(htmlConverter).isNotNull() assertThat(htmlConverter).isNotNull()

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannedString
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -18,16 +19,22 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache 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.DefaultMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.wysiwyg.view.spans.CustomMentionSpan import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -40,101 +47,100 @@ import org.junit.runner.RunWith
class TimelineTextViewTest { class TimelineTextViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID)
private val formatLambda = lambdaRecorder<MentionType, CharSequence> { mentionType -> mentionType.toString() }
private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda)
@Test @Test
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest { fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>" val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(aTextContentWithFormattedBody(charSequence)) val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assert(formatLambda).isNeverCalled()
} }
@Test @Test
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest { fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest {
val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>") val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>")
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(aTextContentWithFormattedBody(charSequence)) val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assert(formatLambda).isNeverCalled()
} }
@Test @Test
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest { fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>" val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null)) val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assertThat(result.toString()).isEqualTo(charSequence) assertThat(result.toString()).isEqualTo(charSequence)
assert(formatLambda).isNeverCalled()
} }
@Test @Test
fun `getTextWithResolvedMentions - with Room mention does nothing`() = runTest { fun `getTextWithResolvedMentions - with Room mention format correctly`() = runTest {
val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias())
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) { inSpans(MentionSpan(mentionType)) {
append(A_ROOM_ID.value) append(A_ROOM_ID.value)
} }
} }
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
val result = rule.getText(aTextContentWithFormattedBody(charSequence)) val expectedDisplayText = mentionType.toString()
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
assertThat(result).isEqualTo(charSequence) assertThat(result).isEqualTo(charSequence)
assert(formatLambda).isCalledOnce()
} }
@Test @Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest { fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID.value)) { inSpans(MentionSpan(mentionType)) {
append("@NotAlice") append("@NotAlice")
} }
} }
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
val result = rule.getText(aTextContentWithFormattedBody(charSequence)) val expectedDisplayText = mentionType.toString()
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice") assert(formatLambda).isCalledOnce()
} }
@Test @Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest { fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) { inSpans(CustomMentionSpan(MentionSpan(mentionType))) {
append("@NotAlice") append("@NotAlice")
} }
} }
val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(aTextContentWithFormattedBody(charSequence)) val expectedDisplayText = mentionType.toString()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice") assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
} assert(formatLambda).isCalledOnce()
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text with user id if no display name is cached`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID_2.value)) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo(A_USER_ID_2.value)
} }
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText( private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
mentionSpanUpdater: MentionSpanUpdater,
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
): CharSequence { ): CharSequence {
val completable = CompletableDeferred<CharSequence>() val completable = CompletableDeferred<CharSequence>()
setContent { setContent {
val roomMemberProfilesCache = RoomMemberProfilesCache().apply {
replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME)))
}
CompositionLocalProvider( CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache, LocalMentionSpanUpdater provides mentionSpanUpdater
) { ) {
completable.complete(getTextWithResolvedMentions(content = content)) completable.complete(getTextWithResolvedMentions(content = content))
} }
@ -142,21 +148,20 @@ class TimelineTextViewTest {
return completable.await() return completable.await()
} }
private fun aMentionSpan( private fun aMentionSpanUpdater(): MentionSpanUpdater {
rawValue: String, return DefaultMentionSpanUpdater(
text: String = "", formatter = mentionSpanFormatter,
type: MentionSpan.Type = MentionSpan.Type.USER theme = mentionSpanTheme,
) = MentionSpan( roomMemberProfilesCache = RoomMemberProfilesCache(),
text = text, roomNamesCache = RoomNamesCache(),
rawValue = rawValue, )
type = type, }
)
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") = private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = null, htmlDocument = null,
formattedBody = formattedBody, formattedBody = formattedBody ?: SpannedString(body),
isEdited = false isEdited = false
) )
} }

View file

@ -89,9 +89,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
body = "body", body = "body",
htmlDocument = null, htmlDocument = null,
plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null, formattedBody = SpannedString("body"),
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -123,9 +122,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
body = "body", body = "body",
htmlDocument = null, htmlDocument = null,
plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null, formattedBody = "body",
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -141,10 +139,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
body = "body", body = "body",
htmlDocument = null, htmlDocument = null,
plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null, formattedBody = SpannedString("body"),
pillifiedBody = SpannableString("body"),
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -160,7 +156,6 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
body = "https://www.example.org", body = "https://www.example.org",
htmlDocument = null, htmlDocument = null,
plainText = "https://www.example.org",
isEdited = false, isEdited = false,
formattedBody = buildSpannedString { formattedBody = buildSpannedString {
inSpans(URLSpan("https://www.example.org")) { inSpans(URLSpan("https://www.example.org")) {
@ -223,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
senderDisambiguatedDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemTextContent).formattedBody).isNull() assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
} }
@Test @Test
@ -637,8 +632,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemNoticeContent( val expected = TimelineItemNoticeContent(
body = "body", body = "body",
htmlDocument = null, htmlDocument = null,
plainText = "body", formattedBody = SpannedString("body"),
formattedBody = null,
isEdited = false, isEdited = false,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@ -671,8 +665,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemEmoteContent( val expected = TimelineItemEmoteContent(
body = "* Bob body", body = "* Bob body",
htmlDocument = null, htmlDocument = null,
plainText = "* Bob body", formattedBody = SpannedString("* Bob body"),
formattedBody = null,
isEdited = false, isEdited = false,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)

View file

@ -10,17 +10,21 @@ package io.element.android.features.messages.impl.utils
import android.net.Uri import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias 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.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.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.FakePermalinkBuilder 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.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -30,90 +34,201 @@ class DefaultTextPillificationHelperTest {
@Test @Test
fun `pillify - adds pills for user ids`() { fun `pillify - adds pills for user ids`() {
val text = "A @user:server.com" val text = "A @user:server.com"
val formatter = FakeMentionSpanFormatter()
val userId = UserId("@user:server.com")
val helper = aTextPillificationHelper( val helper = aTextPillificationHelper(
permalinkparser = FakePermalinkParser(result = { permalinkParser = FakePermalinkParser(result = {
PermalinkData.UserLink(UserId("@user:server.com")) PermalinkData.UserLink(userId)
}), }),
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
Result.success("https://matrix.to/#/@user:server.com") Result.success("https://matrix.to/#/@user:server.com")
}), }),
mentionSpanFormatter = formatter,
) )
val pillified = helper.pillify(text) val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans() val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1) assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.firstOrNull() val mentionSpan = mentionSpans.first()
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER) assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com") val userType = mentionSpan.type as MentionType.User
assertThat(mentionSpan?.text).isEqualTo("@user:server.com") assertThat(userType.userId).isEqualTo(userId)
} val formatted = formatter.formatDisplayText(MentionType.User(userId))
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
@Test
fun `pillify - uses the cached display name for user mentions`() {
val text = "A @user:server.com"
val helper = aTextPillificationHelper(
permalinkparser = FakePermalinkParser(result = {
PermalinkData.UserLink(UserId("@user:server.com"))
}),
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
Result.success("https://matrix.to/#/@user:server.com")
}),
roomMemberProfilesCache = RoomMemberProfilesCache().apply {
replace(listOf(aRoomMember(userId = UserId("@user:server.com"), displayName = "Alice")))
},
)
val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.firstOrNull()
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
assertThat(mentionSpan?.text).isEqualTo("Alice")
} }
@Test @Test
fun `pillify - adds pills for room aliases`() { fun `pillify - adds pills for room aliases`() {
val text = "A #room:server.com" val text = "A #room:server.com"
val roomAlias = RoomAlias("#room:server.com")
val formatter = FakeMentionSpanFormatter()
val helper = aTextPillificationHelper( val helper = aTextPillificationHelper(
permalinkparser = FakePermalinkParser(result = { permalinkParser = FakePermalinkParser(result = {
PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com"))) PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias))
}), }),
permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
Result.success("https://matrix.to/#/#room:server.com") Result.success("https://matrix.to/#/#room:server.com")
}), }),
mentionSpanFormatter = formatter,
) )
val pillified = helper.pillify(text) val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans() val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1) assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.firstOrNull() val mentionSpan = mentionSpans.first()
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM) assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java)
assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com") val roomType = mentionSpan.type as MentionType.Room
assertThat(mentionSpan?.text).isEqualTo("#room:server.com") assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias())
val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias()))
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
} }
@Test @Test
fun `pillify - adds pills for @room mentions`() { fun `pillify - adds pills for @room mentions`() {
val text = "An @room mention" val text = "An @room mention"
val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = { val formatter = FakeMentionSpanFormatter()
PermalinkData.FallbackLink(Uri.EMPTY) val helper = aTextPillificationHelper(
})) permalinkParser = FakePermalinkParser(result = {
PermalinkData.FallbackLink(Uri.EMPTY)
}),
mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text) val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans() val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1) assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.firstOrNull() val mentionSpan = mentionSpans.first()
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE) assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone)
assertThat(mentionSpan?.rawValue).isEqualTo("@room") val formatted = formatter.formatDisplayText(MentionType.Everyone)
assertThat(mentionSpan?.text).isEqualTo("@room") assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
}
@Test
fun `pillify - adds pills for message permalinks`() {
val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
val roomId = RoomId("!roomid:server.com")
val eventId = EventId("$123")
val formatter = FakeMentionSpanFormatter()
val helper = aTextPillificationHelper(
permalinkParser = FakePermalinkParser(result = {
PermalinkData.RoomLink(
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
eventId = eventId
)
}),
permalinkBuilder = FakePermalinkBuilder(),
mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.first()
assertThat(mentionSpan.type).isInstanceOf(MentionType.Message::class.java)
val messageType = mentionSpan.type as MentionType.Message
assertThat(messageType.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
assertThat(messageType.eventId).isEqualTo(eventId)
val formatted = formatter.formatDisplayText(MentionType.Message(roomId.toRoomIdOrAlias(), eventId))
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
}
@Test
fun `pillify - with pillifyPermalinks false does not add pills for permalinks`() {
val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
val roomId = RoomId("!roomid:server.com")
val eventId = EventId("$123")
val formatter = FakeMentionSpanFormatter()
val helper = aTextPillificationHelper(
permalinkParser = FakePermalinkParser(result = {
PermalinkData.RoomLink(
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
eventId = eventId
)
}),
permalinkBuilder = FakePermalinkBuilder(),
mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text, pillifyPermalinks = false)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).isEmpty()
}
@Test
fun `pillify - with pillifyPermalinks false still adds pills for matrix patterns`() {
val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
val userId = UserId("@user:server.com")
val formatter = FakeMentionSpanFormatter()
val helper = aTextPillificationHelper(
permalinkParser = FakePermalinkParser(result = {
PermalinkData.UserLink(userId)
}),
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
Result.success("https://matrix.to/#/@user:server.com")
}),
mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text, pillifyPermalinks = false)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
val mentionSpan = mentionSpans.first()
assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
val userType = mentionSpan.type as MentionType.User
assertThat(userType.userId).isEqualTo(userId)
}
@Test
fun `pillify - with pillifyPermalinks true adds pills for both matrix patterns and permalinks`() {
val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
val userId = UserId("@user:server.com")
val roomId = RoomId("!roomid:server.com")
val eventId = EventId("$123")
val formatter = FakeMentionSpanFormatter()
val permalinkParser = FakePermalinkParser(result = { url ->
if (url.contains("matrix.to")) {
PermalinkData.RoomLink(
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
eventId = eventId
)
} else {
PermalinkData.UserLink(userId)
}
})
val helper = aTextPillificationHelper(
permalinkParser = permalinkParser,
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
Result.success("https://matrix.to/#/@user:server.com")
}),
mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text, pillifyPermalinks = true)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(2)
// Check that we have both a user mention and a message mention
val types = mentionSpans.map { it.type::class.java }
assertThat(types).contains(MentionType.User::class.java)
assertThat(types).contains(MentionType.Message::class.java)
// Verify the user mention
val userMention = mentionSpans.first { it.type is MentionType.User }.type as MentionType.User
assertThat(userMention.userId).isEqualTo(userId)
// Verify the message mention
val messageMention = mentionSpans.first { it.type is MentionType.Message }.type as MentionType.Message
assertThat(messageMention.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
assertThat(messageMention.eventId).isEqualTo(eventId)
} }
private fun aTextPillificationHelper( private fun aTextPillificationHelper(
permalinkparser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser), mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(),
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), ): TextPillificationHelper {
) = DefaultTextPillificationHelper( val mentionSpanProvider = MentionSpanProvider(
mentionSpanProvider = mentionSpanProvider, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, mentionSpanFormatter = mentionSpanFormatter,
permalinkParser = permalinkparser, mentionSpanTheme = MentionSpanTheme(A_USER_ID),
roomMemberProfilesCache = roomMemberProfilesCache, )
) return DefaultTextPillificationHelper(
mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder,
permalinkParser = permalinkParser,
)
}
} }

View file

@ -0,0 +1,19 @@
/*
* 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.features.messages.impl.utils
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionType
class FakeMentionSpanFormatter(
private val formatLambda: (MentionType) -> CharSequence = { type -> type.toString() },
) : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return formatLambda(mentionType)
}
}

View file

@ -8,9 +8,9 @@
package io.element.android.features.messages.impl.utils package io.element.android.features.messages.impl.utils
class FakeTextPillificationHelper( class FakeTextPillificationHelper(
private val pillifyLambda: (CharSequence) -> CharSequence = { it } private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text }
) : TextPillificationHelper { ) : TextPillificationHelper {
override fun pillify(text: CharSequence): CharSequence { override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
return pillifyLambda(text) return pillifyLambda(text, pillifyPermalinks)
} }
} }

View file

@ -9,14 +9,13 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.HtmlConverter
class FakeHtmlConverterProvider( class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it }, private val transform: (String) -> CharSequence = { it },
) : HtmlConverterProvider { ) : HtmlConverterProvider {
@Composable @Composable
override fun Update(currentUserId: UserId) = Unit override fun Update() = Unit
override fun provide(): HtmlConverter { override fun provide(): HtmlConverter {
return object : HtmlConverter { return object : HtmlConverter {

View file

@ -12,13 +12,13 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkParser( class FakePermalinkParser(
private var result: () -> PermalinkData = { lambdaError() } private var result: (String) -> PermalinkData = { lambdaError() }
) : PermalinkParser { ) : PermalinkParser {
fun givenResult(result: PermalinkData) { fun givenResult(result: PermalinkData) {
this.result = { result } this.result = { result }
} }
override fun parse(uriString: String): PermalinkData { override fun parse(uriString: String): PermalinkData {
return result() return result(uriString)
} }
} }

View file

@ -7,32 +7,26 @@
package io.element.android.libraries.matrix.ui.messages package io.element.android.libraries.matrix.ui.messages
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.runningFold
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class RoomMemberProfilesCache @Inject constructor() { class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>()) private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
private val _lastCacheUpdate = MutableStateFlow(0L) suspend fun replace(items: List<RoomMember>) = coroutineScope {
val lastCacheUpdate: StateFlow<Long> = _lastCacheUpdate
fun replace(items: List<RoomMember>) {
cache.value = items.associateBy { it.userId } cache.value = items.associateBy { it.userId }
_lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1)
} }
fun getDisplayName(userId: UserId): String? { fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName return cache.value[userId]?.disambiguatedDisplayName
} }
} }
val LocalRoomMemberProfilesCache = staticCompositionLocalOf {
RoomMemberProfilesCache()
}

View file

@ -0,0 +1,42 @@
/*
* 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.matrix.ui.messages
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
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.roomlist.RoomSummary
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.runningFold
import javax.inject.Inject
@SingleIn(RoomScope::class)
class RoomNamesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<RoomIdOrAlias, String?>())
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
suspend fun replace(items: List<RoomSummary>) = coroutineScope {
val roomNamesByRoomIdOrAlias = LinkedHashMap<RoomIdOrAlias, String?>(items.size * 2)
items
.forEach { summary ->
roomNamesByRoomIdOrAlias[summary.info.id.toRoomIdOrAlias()] = summary.info.name
val canonicalAlias = summary.info.canonicalAlias
if (canonicalAlias != null) {
roomNamesByRoomIdOrAlias[canonicalAlias.toRoomIdOrAlias()] = summary.info.name
}
}
cache.value = roomNamesByRoomIdOrAlias
}
fun getDisplayName(roomIdOrAlias: RoomIdOrAlias): String? {
return cache.value[roomIdOrAlias]
}
}

View file

@ -106,6 +106,7 @@ fun TextComposer(
onReceiveSuggestion: (Suggestion?) -> Unit, onReceiveSuggestion: (Suggestion?) -> Unit,
onSelectRichContent: ((Uri) -> Unit)?, onSelectRichContent: ((Uri) -> Unit)?,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay, resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
resolveAtRoomMentionDisplay: () -> TextDisplay,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,
subcomposing: Boolean = false, subcomposing: Boolean = false,
@ -176,7 +177,7 @@ fun TextComposer(
composerMode = composerMode, composerMode = composerMode,
onResetComposerMode = onResetComposerMode, onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, resolveRoomMentionDisplay = resolveAtRoomMentionDisplay,
onError = onError, onError = onError,
onTyping = onTyping, onTyping = onTyping,
onSelectRichContent = onSelectRichContent, onSelectRichContent = onSelectRichContent,
@ -930,6 +931,7 @@ private fun ATextComposer(
onTyping = {}, onTyping = {},
onReceiveSuggestion = {}, onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onSelectRichContent = null, 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.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle 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.MentionSpan
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
@ -75,7 +74,7 @@ fun MarkdownTextInput(
} }
} }
val mentionSpanTheme = LocalMentionSpanTheme.current val mentionSpanUpdater = LocalMentionSpanUpdater.current
AndroidView( AndroidView(
modifier = Modifier modifier = Modifier
@ -124,10 +123,9 @@ fun MarkdownTextInput(
}, },
update = { editText -> update = { editText ->
editText.applyStyleInCompose(richTextEditorStyle) editText.applyStyleInCompose(richTextEditorStyle)
val text = state.text.value()
mentionSpanUpdater.updateMentionSpans(text)
if (state.text.needsDisplaying()) { if (state.text.needsDisplaying()) {
val text = state.text.value()
mentionSpanTheme.updateMentionStyles(text)
editText.updateEditableText(text) editText.updateEditableText(text)
if (canUpdateState) { if (canUpdateState) {
state.text.update(editText.editableText, false) state.text.update(editText.editableText, false)

View file

@ -11,119 +11,153 @@ import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import android.text.style.ReplacementSpan import android.text.style.ReplacementSpan
import androidx.core.text.getSpans 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 io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlin.math.min
import kotlin.math.roundToInt 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( class MentionSpan(
text: String, val type: MentionType,
val rawValue: String,
val type: Type,
) : ReplacementSpan() { ) : ReplacementSpan() {
companion object { private val backgroundPaint = Paint()
private const val MAX_LENGTH = 20 private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
}
var backgroundColor: Int = 0 private var backgroundColor: Int = 0
var textColor: Int = 0 private var textColor: Int = 0
var startPadding: Int = 0 private var startPadding: Int = 0
var endPadding: Int = 0 private var endPadding: Int = 0
var typeface: Typeface = Typeface.DEFAULT private var typeface: Typeface = Typeface.DEFAULT
private var textWidth = 0 private var measuredTextWidth = 0
private val backgroundPaint = Paint().apply {
isAntiAlias = true
color = backgroundColor
}
var text: String = text // The formatted display text, will be set by the formatter
set(value) { var displayText: CharSequence = ""
field = value private set
mentionText = getActualText(text)
/**
* 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) { backgroundColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
Type.ROOM -> mentionSpanTheme.otherBackgroundColor is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor
Type.EVERYONE -> mentionSpanTheme.currentUserBackgroundColor is MentionType.Room -> mentionSpanTheme.otherBackgroundColor
is MentionType.Message -> mentionSpanTheme.otherBackgroundColor
} }
textColor = when (type) { textColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
Type.ROOM -> mentionSpanTheme.otherTextColor is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor
Type.EVERYONE -> mentionSpanTheme.currentUserTextColor is MentionType.Room -> mentionSpanTheme.otherTextColor
is MentionType.Message -> mentionSpanTheme.otherTextColor
} }
backgroundPaint.color = backgroundColor
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
startPadding = startPaddingPx startPadding = startPaddingPx
endPadding = endPaddingPx endPadding = endPaddingPx
typeface = mentionSpanTheme.typeface.value typeface = mentionSpanTheme.typeface.value
} }
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { /**
paint.typeface = typeface * Updates the display text using a formatter.
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() */
return textWidth + startPadding + endPadding 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 // 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 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 val radius = rect.height() / 2
canvas.drawRoundRect(rect, radius, radius, backgroundPaint) 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 { textPaint.set(paint)
return buildString { textPaint.color = textColor
val mentionText = text.orEmpty() textPaint.typeface = typeface
when (type) {
Type.USER -> { val availableWidthForText = availableWidth - startPadding - endPadding
if (text.firstOrNull() != '@') { val textToDraw = if (measuredTextWidth > availableWidthForText) {
append("@") TextUtils.ellipsize(
} displayText,
} textPaint,
Type.ROOM -> { availableWidthForText,
if (text.firstOrNull() != '#') { TextUtils.TruncateAt.END
append("#") )
} } else {
} displayText
Type.EVERYONE -> Unit
}
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
if (mentionText.length > MAX_LENGTH) {
append("")
}
} }
} canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
enum class Type {
USER,
ROOM,
EVERYONE,
} }
} }
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) { return if (this is android.text.Spanned) {
val customMentionSpans = getSpans<CustomMentionSpan>() // If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
if (customMentionSpans.isNotEmpty()) { val customMentionSpans = getSpans<CustomMentionSpan>(start, end)
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them .map { it.providedSpan }
customMentionSpans.map { it.providedSpan }.filterIsInstance<MentionSpan>() .filterIsInstance<MentionSpan>()
} else { // Collect all direct mention spans
// Otherwise try to get the spans directly val directMentionSpans = getSpans<MentionSpan>(start, end)
getSpans<MentionSpan>().toList() // Return the union of both
} customMentionSpans + directMentionSpans
} else { } else {
emptyList() 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 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.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import javax.inject.Inject import javax.inject.Inject
@Stable private const val EVERYONE_MENTION_TEXT = "@room"
/**
* Provider for [MentionSpan]s.
*/
open class MentionSpanProvider @Inject constructor( open class MentionSpanProvider @Inject constructor(
private val permalinkParser: PermalinkParser, 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) val permalinkData = permalinkParser.parse(url)
return when { return getMentionSpanFor(text, permalinkData)
permalinkData is PermalinkData.UserLink -> { }
MentionSpan(
text = text, /**
rawValue = permalinkData.userId.toString(), * Creates a mention span from a text and permalink data.
type = MentionSpan.Type.USER, *
) * @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 -> { is PermalinkData.RoomLink -> {
MentionSpan( val eventId = permalinkData.eventId
text = text, if (eventId != null) {
rawValue = "@room", createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId)
type = MentionSpan.Type.EVERYONE, } else {
) createRoomMentionSpan(permalinkData.roomIdOrAlias)
}
} }
permalinkData is PermalinkData.RoomLink -> { is PermalinkData.FallbackLink -> {
MentionSpan( if (text == EVERYONE_MENTION_TEXT) {
text = text, createEveryoneMentionSpan()
rawValue = permalinkData.roomIdOrAlias.identifier, } else {
type = MentionSpan.Type.ROOM, null
) }
}
else -> {
MentionSpan(
text = text,
rawValue = text,
type = MentionSpan.Type.ROOM,
)
} }
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 android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection 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.currentUserMentionPillText
import io.element.android.libraries.designsystem.theme.mentionPillBackground import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText 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.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId 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.core.toRoomIdOrAlias
@ -45,13 +46,14 @@ import javax.inject.Inject
/** /**
* Theme used for mention spans. * Theme used for mention spans.
* To make this work, you need to: * To make this work, you need to:
* 1. Provide [LocalMentionSpanTheme] in a composable that wraps the ones where you want to use mentions. * 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed.
* 2. Call [MentionSpanTheme.updateStyles] with the current [UserId] so the colors and sizes are computed. * 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans.
* 3. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.update] to update the styles of the mention spans.
*/ */
@Stable @Stable
class MentionSpanTheme @Inject constructor() { @SingleIn(SessionScope::class)
internal var currentUserId: UserId? = null class MentionSpanTheme(val currentUserId: UserId) {
@Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
internal var currentUserTextColor: Int = 0 internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0 internal var otherTextColor: Int = 0
@ -66,8 +68,7 @@ class MentionSpanTheme @Inject constructor() {
*/ */
@Suppress("ComposableNaming") @Suppress("ComposableNaming")
@Composable @Composable
fun updateStyles(currentUserId: UserId) { fun updateStyles() {
this.currentUserId = currentUserId
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb() otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
@ -93,24 +94,28 @@ fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) {
val spanned = charSequence as? Spanned ?: return val spanned = charSequence as? Spanned ?: return
val mentionSpans = spanned.getMentionSpans() val mentionSpans = spanned.getMentionSpans()
for (span in mentionSpans) { for (span in mentionSpans) {
span.update(this) span.updateTheme(this)
} }
} }
/** @PreviewsDayNight
* Composition local containing the current [MentionSpanTheme]. @Composable
*/ internal fun MentionSpanThemePreview() {
val LocalMentionSpanTheme = staticCompositionLocalOf {
MentionSpanTheme()
}
@PreviewsDayNight
@Composable
internal fun MentionSpanThemePreview() {
ElementPreview { ElementPreview {
val mentionSpanTheme = remember { MentionSpanTheme() } val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
val provider = remember { val provider = remember {
MentionSpanProvider( 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 { permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData { override fun parse(uriString: String): PermalinkData {
return when (uriString) { return when (uriString) {
@ -133,36 +138,31 @@ val LocalMentionSpanTheme = staticCompositionLocalOf {
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other: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 mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org")
fun mentionSpanEveryone() = provider.getMentionSpanFor("@room", "@room") fun mentionSpanEveryone() = provider.createEveryoneMentionSpan()
mentionSpanTheme.updateStyles(currentUserId = UserId("@me:matrix.org")) mentionSpanTheme.updateStyles()
CompositionLocalProvider( AndroidView(factory = { context ->
LocalMentionSpanTheme provides mentionSpanTheme TextView(context).apply {
) { includeFontPadding = false
AndroidView(factory = { context -> layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
TextView(context).apply { text = buildSpannedString {
includeFontPadding = false append("This is a ")
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) append("@mention", mentionSpanMe(), 0)
text = buildSpannedString { append(" to the current user and this is a ")
append("This is a ") append("@mention", mentionSpanOther(), 0)
append("@mention", mentionSpanMe(), 0) append(" to other user. This is for everyone in the ")
append(" to the current user and this is a ") append("@room", mentionSpanEveryone(), 0)
append("@mention", mentionSpanOther(), 0) append(". This one is for a link to another room: ")
append(" to other user. This is for everyone in the ") append("#room:matrix.org", mentionSpanRoom(), 0)
append("@room", mentionSpanEveryone(), 0) append("\n\n")
append(". This one is for a link to another room: ") append("This ")
append("#room:matrix.org", mentionSpanRoom(), 0) append("mention", mentionSpanMe(), 0)
append("\n\n") append(" didn't have an '@' and it was automatically added, same as this ")
append("This ") append("room:matrix.org", mentionSpanRoom(), 0)
append("mention", mentionSpanMe(), 0) append(" one, which had no leading '#'.")
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)
} }
}) 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.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.text.getSpans import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence 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.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -48,39 +47,33 @@ class MarkdownTextEditorState(
fun insertSuggestion( fun insertSuggestion(
resolvedSuggestion: ResolvedSuggestion, resolvedSuggestion: ResolvedSuggestion,
mentionSpanProvider: MentionSpanProvider, mentionSpanProvider: MentionSpanProvider,
permalinkBuilder: PermalinkBuilder,
) { ) {
val suggestion = currentSuggestion ?: return val suggestion = currentSuggestion ?: return
when (resolvedSuggestion) { when (resolvedSuggestion) {
is ResolvedSuggestion.AtRoom -> { is ResolvedSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value()) val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room" val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
currentText.replace(suggestion.start, suggestion.end, "@ ") currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1 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) text.update(currentText, true)
selection = IntRange(end + 1, end + 1) selection = IntRange(end + 1, end + 1)
} }
is ResolvedSuggestion.Member -> { is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value()) val currentText = SpannableStringBuilder(text.value())
val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, "@ ") currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1 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.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1) this.selection = IntRange(end + 1, end + 1)
} }
is ResolvedSuggestion.Alias -> { is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value()) val currentText = SpannableStringBuilder(text.value())
val text = resolvedSuggestion.roomAlias.value val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, "# ") currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1 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.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1) this.selection = IntRange(end + 1, end + 1)
} }
@ -98,19 +91,23 @@ class MarkdownTextEditorState(
val start = charSequence.getSpanStart(mention) val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention) val end = charSequence.getSpanEnd(mention)
when (mention.type) { when (mention.type) {
MentionSpan.Type.USER -> { is MentionType.User -> {
permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link -> permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link ->
replace(start, end, "[${mention.rawValue}]($link)") replace(start, end, "[${mention.type.userId}]($link)")
} }
} }
MentionSpan.Type.EVERYONE -> { is MentionType.Everyone -> {
replace(start, end, "@room") replace(start, end, "@room")
} }
MentionSpan.Type.ROOM -> { is MentionType.Room -> {
permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link -> val roomIdOrAlias = mention.type.roomIdOrAlias
replace(start, end, "[${mention.text}]($link)") 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> { fun getMentions(): List<IntentionalMention> {
val text = SpannableString(text.value()) val text = SpannableString(text.value())
val mentionSpans = text.getSpans<MentionSpan>(0, text.length) val mentionSpans = text.getMentionSpans()
return mentionSpans.mapNotNull { mentionSpan -> return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) { when (mentionSpan.type) {
MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue)) is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId)
MentionSpan.Type.EVERYONE -> IntentionalMention.Room is MentionType.Everyone -> IntentionalMention.Room
MentionSpan.Type.ROOM -> null else -> null
} }
} }
} }

View file

@ -16,14 +16,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.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.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput 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.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -146,7 +145,6 @@ class MarkdownTextInputTest {
@Test @Test
fun `inserting a mention replaces the existing text with a span`() = runTest { fun `inserting a mention replaces the existing text with a span`() = runTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) 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) val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state) rule.setMarkdownTextInput(state = state)
@ -155,8 +153,7 @@ class MarkdownTextInputTest {
editor = it.findEditor() editor = it.findEditor()
state.insertSuggestion( state.insertSuggestion(
ResolvedSuggestion.Member(roomMember = aRoomMember()), ResolvedSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(permalinkParser = permalinkParser), aMentionSpanProvider(permalinkParser),
permalinkBuilder,
) )
} }
rule.awaitIdle() 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.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -28,22 +27,22 @@ class IntentionalMentionSpanProviderTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val permalinkParser = FakePermalinkParser() private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider( private val mentionSpanProvider = aMentionSpanProvider(permalinkParser)
permalinkParser = permalinkParser,
)
@Test @Test
fun `getting mention span for a user returns a MentionSpan of type USER`() { fun `getting mention span for a user returns a MentionSpan of type USER`() {
permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID)) permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}") 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 @Test
fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() { fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE) assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone)
} }
@Test @Test
@ -54,6 +53,8 @@ class IntentionalMentionSpanProviderTest {
) )
) )
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") 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.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat 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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.IntentionalMention 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.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember 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.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.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
@ -35,9 +38,8 @@ class MarkdownTextEditorStateTest {
fun `insertMention - room alias - getMentions return empty list`() { fun `insertMention - room alias - getMentions return empty list`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val suggestion = aRoomAliasSuggestion() val suggestion = aRoomAliasSuggestion()
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider() val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty() assertThat(state.getMentions()).isEmpty()
} }
@ -48,9 +50,8 @@ class MarkdownTextEditorStateTest {
} }
val suggestion = aRoomAliasSuggestion() val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) state.insertSuggestion(suggestion, mentionSpanProvider)
} }
@Test @Test
@ -60,9 +61,8 @@ class MarkdownTextEditorStateTest {
} }
val suggestion = aRoomAliasSuggestion() val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) 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) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) state.insertSuggestion(suggestion, mentionSpanProvider)
} }
@Test @Test
@ -70,31 +70,11 @@ class MarkdownTextEditorStateTest {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember() val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member) val mention = ResolvedSuggestion.Member(member)
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider() val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(mention, mentionSpanProvider)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
assertThat(state.getMentions()).isEmpty() 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 @Test
fun `insertSuggestion - with member`() { fun `insertSuggestion - with member`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
@ -103,10 +83,9 @@ class MarkdownTextEditorStateTest {
val member = aRoomMember() val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member) val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions() val mentions = state.getMentions()
assertThat(mentions).isNotEmpty() assertThat(mentions).isNotEmpty()
@ -119,11 +98,10 @@ class MarkdownTextEditorStateTest {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
} }
val mention = ResolvedSuggestion.AtRoom val mention = ResolvedSuggestion.AtRoom
val permalinkBuilder = FakePermalinkBuilder()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions() val mentions = state.getMentions()
assertThat(mentions).isNotEmpty() assertThat(mentions).isNotEmpty()
@ -177,16 +155,10 @@ class MarkdownTextEditorStateTest {
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
} }
private fun aMentionSpanProvider(
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider {
return MentionSpanProvider(permalinkParser)
}
private fun aMarkdownTextWithMentions(): CharSequence { private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER) val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org")))
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE) val atRoomMentionSpan = MentionSpan(MentionType.Everyone)
val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM) val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias()))
return buildSpannedString { return buildSpannedString {
append("Hello ") append("Hello ")
inSpans(userMentionSpan) { inSpans(userMentionSpan) {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f541f0a05b49272ef3e379a9a629c71458b37435928338a3a58ac852b38eb803 oid sha256:73a225eeb9c217ada941b5add96dd2f832fb33b702f86eae5e1f5ecbee5a4bb2
size 48802 size 51625

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a542b3be557dddc3aba16bda8bb7db1bf0078e6bb3abcc42a11c723e8dba3504 oid sha256:ab365d585b76ab0723c9998cc13f755640b7f086b99b55804a38e103b8d42416
size 46683 size 49440

View file

@ -227,7 +227,7 @@ Compose:
- LocalMediaItemPresenterFactories - LocalMediaItemPresenterFactories
- LocalTimelineItemPresenterFactories - LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache - LocalRoomMemberProfilesCache
- LocalMentionSpanTheme - LocalMentionSpanUpdater
- LocalAnalyticsService - LocalAnalyticsService
- LocalBuildMeta - LocalBuildMeta
CompositionLocalNaming: CompositionLocalNaming: