From 042c0c5a6b266af614b761bea7a4142d745bf2dd Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 28 Mar 2025 11:20:32 +0100 Subject: [PATCH] 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 --- .../appnav/intent/IntentResolverTest.kt | 2 +- .../api/timeline/HtmlConverterProvider.kt | 3 +- .../messages/impl/MessagesFlowNode.kt | 35 ++- .../messages/impl/MessagesPresenter.kt | 2 +- .../preview/AttachmentsPreviewView.kt | 1 + .../MessageComposerPresenter.kt | 33 ++- .../messagecomposer/MessageComposerState.kt | 1 + .../MessageComposerStateProvider.kt | 1 + .../messagecomposer/MessageComposerView.kt | 1 + .../timeline/DefaultHtmlConverterProvider.kt | 27 ++- .../TimelineItemEventRowTimestampPreview.kt | 2 +- .../components/event/TimelineItemTextView.kt | 54 +---- .../TimelineItemContentMessageFactory.kt | 27 ++- .../model/event/TimelineItemEmoteContent.kt | 5 +- .../event/TimelineItemEventContentProvider.kt | 42 ++-- .../model/event/TimelineItemNoticeContent.kt | 5 +- .../event/TimelineItemTextBasedContent.kt | 8 +- .../model/event/TimelineItemTextContent.kt | 5 +- .../impl/utils/TextPillificationHelper.kt | 90 ++++--- .../actionlist/ActionListPresenterTest.kt | 26 +- .../impl/fixtures/MessageEventFixtures.kt | 2 +- .../MessageComposerPresenterTest.kt | 11 +- .../DefaultHtmlConverterProviderTest.kt | 16 +- .../components/event/TimelineTextViewTest.kt | 109 +++++---- .../TimelineItemContentMessageFactoryTest.kt | 19 +- .../DefaultTextPillificationHelperTest.kt | 223 +++++++++++++----- .../impl/utils/FakeMentionSpanFormatter.kt | 19 ++ .../impl/utils/FakeTextPillificationHelper.kt | 6 +- .../timeline/FakeHtmlConverterProvider.kt | 3 +- .../test/permalink/FakePermalinkParser.kt | 4 +- .../ui/messages/RoomMemberProfilesCache.kt | 16 +- .../matrix/ui/messages/RoomNamesCache.kt | 42 ++++ .../libraries/textcomposer/TextComposer.kt | 4 +- .../components/markdown/MarkdownTextInput.kt | 10 +- .../textcomposer/mentions/MentionSpan.kt | 190 +++++++++------ .../mentions/MentionSpanFormatter.kt | 75 ++++++ .../mentions/MentionSpanProvider.kt | 130 +++++++--- .../textcomposer/mentions/MentionSpanTheme.kt | 102 ++++---- .../mentions/MentionSpanUpdater.kt | 67 ++++++ .../model/MarkdownTextEditorState.kt | 51 ++-- .../markdown/MarkdownTextInputTest.kt | 7 +- .../IntentionalMentionSpanProviderTest.kt | 17 +- .../impl/mentions/MentionSpanFormatterTest.kt | 135 +++++++++++ .../mentions/MentionSpanProviderFixture.kt | 32 +++ .../impl/model/MarkdownTextEditorStateTest.kt | 54 +---- ...ser.mentions_MentionSpanTheme_Day_0_en.png | 4 +- ...r.mentions_MentionSpanTheme_Night_0_en.png | 4 +- tools/detekt/detekt.yml | 2 +- 48 files changed, 1156 insertions(+), 568 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 8640fb03c8..0d79b5847a 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -241,7 +241,7 @@ class IntentResolverTest { } private fun createIntentResolver( - permalinkParserResult: () -> PermalinkData = { lambdaError() } + permalinkParserResult: (String) -> PermalinkData = { lambdaError() } ): IntentResolver { return IntentResolver( deeplinkParser = DeeplinkParser(), diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt index 5071613e58..607283792f 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt @@ -8,12 +8,11 @@ package io.element.android.features.messages.api.timeline import androidx.compose.runtime.Composable -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.wysiwyg.utils.HtmlConverter interface HtmlConverterProvider { @Composable - fun Update(currentUserId: UserId) + fun Update() fun provide(): HtmlConverter } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 09bc3a287f..6feaa23b62 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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.operation.hide 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.DateFormatterMode 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.timeline.Timeline 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.RoomNamesCache import io.element.android.libraries.mediaviewer.api.MediaInfo 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.MentionSpanUpdater import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -100,11 +103,14 @@ class MessagesFlowNode @AssistedInject constructor( private val locationService: LocationService, private val room: MatrixRoom, private val roomMemberProfilesCache: RoomMemberProfilesCache, + private val roomNamesCache: RoomNamesCache, + private val mentionSpanUpdater: MentionSpanUpdater, private val mentionSpanTheme: MentionSpanTheme, private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, private val timelineController: TimelineController, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val dateFormatter: DateFormatter, + private val coroutineDispatchers: CoroutineDispatchers, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialTarget.toNavTarget(), @@ -172,13 +178,29 @@ class MessagesFlowNode @AssistedInject constructor( timelineController.close() } ) + setupCacheUpdaters() + + pinnedEventsTimelineProvider.launchIn(lifecycleScope) + } + + private fun setupCacheUpdaters() { room.membersStateFlow .onEach { membersState -> - roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) + withContext(coroutineDispatchers.computation) { + roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) + } } .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 { @@ -465,10 +487,9 @@ class MessagesFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - mentionSpanTheme.updateStyles(currentUserId = room.sessionId) + mentionSpanTheme.updateStyles() CompositionLocalProvider( - LocalRoomMemberProfilesCache provides roomMemberProfilesCache, - LocalMentionSpanTheme provides mentionSpanTheme, + LocalMentionSpanUpdater provides mentionSpanUpdater ) { BackstackWithOverlayBox(modifier) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 405bc2c536..4541e267fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -128,7 +128,7 @@ class MessagesPresenter @AssistedInject constructor( @Composable override fun present(): MessagesState { - htmlConverterProvider.Update(currentUserId = room.sessionId) + htmlConverterProvider.Update() val roomInfo by room.roomInfoFlow.collectAsState() val localCoroutineScope = rememberCoroutineScope() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index ca9faddfee..8b66fd9e62 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions( onDeleteVoiceMessage = {}, onReceiveSuggestion = {}, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, onError = {}, onTyping = {}, onSelectRichContent = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 8489139bb1..73057b1a51 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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.matrix.api.core.UserId 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.room.IntentionalMention 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.timeline.TimelineException 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.map 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.PermissionsPresenter 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.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState @@ -119,7 +116,6 @@ class MessageComposerPresenter @AssistedInject constructor( private val draftService: ComposerDraftService, private val mentionSpanProvider: MentionSpanProvider, private val pillificationHelper: TextPillificationHelper, - private val roomMemberProfilesCache: RoomMemberProfilesCache, private val suggestionsProcessor: SuggestionsProcessor, ) : Presenter { @AssistedFactory @@ -331,7 +327,6 @@ class MessageComposerPresenter @AssistedInject constructor( markdownTextEditorState.insertSuggestion( resolvedSuggestion = event.resolvedSuggestion, mentionSpanProvider = mentionSpanProvider, - permalinkBuilder = permalinkBuilder, ) suggestionSearchTrigger.value = null } @@ -344,23 +339,24 @@ class MessageComposerPresenter @AssistedInject constructor( } } - val mentionSpanTheme = LocalMentionSpanTheme.current - val resolveMentionDisplay = remember(mentionSpanTheme) { + val resolveMentionDisplay = remember { { text: String, url: String -> - val permalinkData = permalinkParser.parse(url) - if (permalinkData is PermalinkData.UserLink) { - val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value - val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url) - mentionSpan.update(mentionSpanTheme) + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) + if (mentionSpan != null) { TextDisplay.Custom(mentionSpan) } else { - val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) - mentionSpan.update(mentionSpanTheme) - TextDisplay.Custom(mentionSpan) + TextDisplay.Plain } } } + val resolveAtRoomMentionDisplay = remember { + { + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + TextDisplay.Custom(mentionSpan) + } + } + return MessageComposerState( textEditorState = textEditorState, isFullScreen = isFullScreen.value, @@ -371,6 +367,7 @@ class MessageComposerPresenter @AssistedInject constructor( canCreatePoll = canCreatePoll.value, suggestions = suggestions.toPersistentList(), resolveMentionDisplay = resolveMentionDisplay, + resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, eventSink = { handleEvents(it) }, ) } @@ -640,8 +637,8 @@ class MessageComposerPresenter @AssistedInject constructor( analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) } else { val markdown = richTextEditorState.messageMarkdown - val pilliefiedMarkdown = pillificationHelper.pillify(markdown) - markdownTextEditorState.text.update(pilliefiedMarkdown, true) + val markdownWithMentions = pillificationHelper.pillify(markdown, false) + markdownTextEditorState.text.update(markdownWithMentions, true) // Give some time for the focus of the previous editor to be cleared delay(100) markdownTextEditorState.requestFocusAction() @@ -709,7 +706,7 @@ class MessageComposerPresenter @AssistedInject constructor( if (content.isEmpty()) { markdownTextEditorState.selection = IntRange.EMPTY } - val pillifiedContent = pillificationHelper.pillify(content) + val pillifiedContent = pillificationHelper.pillify(content, false) markdownTextEditorState.text.update(pillifiedContent, true) if (requestFocus) { markdownTextEditorState.requestFocusAction() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index d86ba8d98c..1f1f80b203 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -25,5 +25,6 @@ data class MessageComposerState( val canCreatePoll: Boolean, val suggestions: ImmutableList, val resolveMentionDisplay: (String, String) -> TextDisplay, + val resolveAtRoomMentionDisplay: () -> TextDisplay, val eventSink: (MessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index c0ea895618..590cfd20cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -43,5 +43,6 @@ fun aMessageComposerState( canCreatePoll = canCreatePoll, suggestions = suggestions, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index bfb51f4a87..42c68255e8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -113,6 +113,7 @@ internal fun MessageComposerView( onDeleteVoiceMessage = onDeleteVoiceMessage, onReceiveSuggestion = ::onSuggestionReceived, resolveMentionDisplay = state.resolveMentionDisplay, + resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay, onError = ::onError, onTyping = ::onTyping, onSelectRichContent = ::sendUri, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt index 8cdf5a1626..78e763fcc2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt @@ -16,11 +16,9 @@ import androidx.compose.ui.platform.LocalInspectionMode import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.messages.api.timeline.HtmlConverterProvider 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.matrix.api.core.UserId 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.wysiwyg.compose.StyledHtmlConverter import io.element.android.wysiwyg.display.MentionDisplayHandler @@ -29,42 +27,45 @@ import io.element.android.wysiwyg.utils.HtmlConverter import uniffi.wysiwyg_composer.newMentionDetector import javax.inject.Inject -@ContributesBinding(SessionScope::class) -@SingleIn(SessionScope::class) +@ContributesBinding(RoomScope::class) +@SingleIn(RoomScope::class) class DefaultHtmlConverterProvider @Inject constructor( private val mentionSpanProvider: MentionSpanProvider, ) : HtmlConverterProvider { private val htmlConverter: MutableState = mutableStateOf(null) @Composable - override fun Update(currentUserId: UserId) { + override fun Update() { val isInEditMode = LocalInspectionMode.current val mentionDetector = remember(isInEditMode) { if (isInEditMode) null else newMentionDetector() } val editorStyle = ElementRichTextEditorStyle.textStyle() - val mentionSpanTheme = LocalMentionSpanTheme.current val context = LocalContext.current - htmlConverter.value = remember(editorStyle, mentionSpanTheme) { + htmlConverter.value = remember(editorStyle) { StyledHtmlConverter( context = context, mentionDisplayHandler = object : MentionDisplayHandler { override fun resolveAtRoomMentionDisplay(): TextDisplay { - val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#") - mentionSpan.update(mentionSpanTheme) + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() return TextDisplay.Custom(mentionSpan) } override fun resolveMentionDisplay(text: String, url: String): TextDisplay { val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) - mentionSpan.update(mentionSpanTheme) - return TextDisplay.Custom(mentionSpan) + return if (mentionSpan != null) { + TextDisplay.Custom(mentionSpan) + } else { + TextDisplay.Plain + } } }, isEditor = false, - isMention = { _, url -> mentionDetector?.isMention(url).orFalse() } + isMention = { _, url -> + mentionDetector?.isMention(url).orFalse() + } ).apply { configureWith(editorStyle) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt index 8933538b8a..0555670e46 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt @@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview( event = event.copy( content = event.content.copy( body = str, - pillifiedBody = str, + formattedBody = str, ), reactionsState = aTimelineItemReactions(count = 0), ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index aed72254c6..a4a7cff9cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -14,9 +14,6 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable 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.semantics.contentDescription 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.designsystem.preview.ElementPreview 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.mentions.LocalMentionSpanTheme -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.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link @@ -51,7 +42,7 @@ fun TimelineItemTextView( modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { - val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) && + val emojiOnly = content.formattedBody.toString() == content.body && content.body.replace(" ", "").containsOnlyEmojis() val textStyle = when { emojiOnly -> ElementTheme.typography.fontHeadingXlRegular @@ -61,10 +52,10 @@ fun TimelineItemTextView( LocalContentColor provides ElementTheme.colors.textPrimary, LocalTextStyle provides textStyle ) { - val body = getTextWithResolvedMentions(content) + val text = getTextWithResolvedMentions(content) Box(modifier.semantics { contentDescription = content.plainText }) { EditorStyledText( - text = body, + text = text, onLinkClickedListener = onLinkClick, onLinkLongClickedListener = onLinkLongClick, style = ElementRichTextEditorStyle.textStyle(), @@ -78,36 +69,9 @@ fun TimelineItemTextView( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @Composable internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence { - val userProfileCache = LocalRoomMemberProfilesCache.current - val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState() - val mentionSpanTheme = LocalMentionSpanTheme.current - 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 + val mentionSpanUpdater = LocalMentionSpanUpdater.current + val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody) + return SpannableString(bodyWithResolvedMentions) } @PreviewsDayNight @@ -126,7 +90,7 @@ internal fun TimelineItemTextViewPreview( @Composable internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview { 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( content = content, @@ -139,7 +103,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview { @Composable internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview { 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( content = content, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index d7d267ce48..f3b54dae53 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -69,13 +69,16 @@ class TimelineItemContentMessageFactory @Inject constructor( return when (val messageType = content.type) { is EmoteMessageType -> { val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" + val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify( + emoteBody + ).safeLinkify() TimelineItemEmoteContent( body = emoteBody, htmlDocument = messageType.formatted?.toHtmlDocument( permalinkParser = permalinkParser, prefix = "* $senderDisambiguatedDisplayName", ), - formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(), + formattedBody = formattedBody, isEdited = content.isEdited, ) } @@ -123,10 +126,8 @@ class TimelineItemContentMessageFactory @Inject constructor( val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, - pillifiedBody = textPillificationHelper.pillify(body), htmlDocument = null, - plainText = body, - formattedBody = null, + formattedBody = body, isEdited = content.isEdited, ) } else { @@ -219,20 +220,26 @@ class TimelineItemContentMessageFactory @Inject constructor( } is NoticeMessageType -> { val body = messageType.body.trimEnd() + val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( + body + ).safeLinkify() + val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) TimelineItemNoticeContent( body = body, - htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), - formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), + htmlDocument = htmlDocument, + formattedBody = formattedBody, isEdited = content.isEdited, ) } is TextMessageType -> { val body = messageType.body.trimEnd() + val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( + body + ).safeLinkify() TimelineItemTextContent( body = body, - pillifiedBody = textPillificationHelper.pillify(body).safeLinkify(), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), - formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), + formattedBody = formattedBody, isEdited = content.isEdited, ) } @@ -240,9 +247,8 @@ class TimelineItemContentMessageFactory @Inject constructor( val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, - pillifiedBody = textPillificationHelper.pillify(body), htmlDocument = null, - formattedBody = body.withLinks(), + formattedBody = textPillificationHelper.pillify(body).safeLinkify(), isEdited = content.isEdited, ) } @@ -263,6 +269,7 @@ class TimelineItemContentMessageFactory @Inject constructor( if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null val result = htmlConverterProvider.provide() .fromHtmlToSpans(formattedBody.body.trimEnd()) + .let { textPillificationHelper.pillify(it) } .safeLinkify() return if (prefix != null) { buildSpannedString { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt index 82cd5a3f39..dc04e5b269 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -12,11 +12,10 @@ import org.jsoup.nodes.Document data class TimelineItemEmoteContent( override val body: String, - override val pillifiedBody: CharSequence = body, override val htmlDocument: Document?, - override val plainText: String = htmlDocument?.toPlainText() ?: body, - override val formattedBody: CharSequence?, + override val formattedBody: CharSequence, override val isEdited: Boolean, ) : TimelineItemTextBasedContent { override val type: String = "TimelineItemEmoteContent" + override val plainText: String = htmlDocument?.toPlainText() ?: body } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 09484ac7e3..36c32222be 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import org.jsoup.nodes.Document class TimelineItemEventContentProvider : PreviewParameterProvider { override val values = sequenceOf( @@ -58,35 +59,46 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider { - val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty() - if (!mentionSpanExists) { - val userId = UserId(match.value) - val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue - val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink) - roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it } - spannable.replace(match.start, match.end, "@ ") - spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val userId = UserId(match.value) + val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId) + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink -> + // Also add a URLSpan in case of raw user id so it can be clicked + val urlSpan = URLSpan(permalink) + text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } } MatrixPatternType.ROOM_ALIAS -> { - val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty() - if (!mentionSpanExists) { - val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue - val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink) - spannable.replace(match.start, match.end, "@ ") - spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val roomAlias = RoomAlias(match.value) + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias()) + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink -> + // 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 -> { - val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty() - if (!mentionSpanExists) { - val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "") - spannable.replace(match.start, match.end, "@ ") - spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } 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(start, end).isNotEmpty()) return false + if (getSpans(start, end).isNotEmpty()) return false + return true } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 8a5b388f72..e5e6ff5ea2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -153,7 +153,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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( ActionListEvents.ComputeForMessage( @@ -201,7 +201,7 @@ class ActionListPresenterTest { isMine = false, isEditable = false, 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( ActionListEvents.ComputeForMessage( @@ -248,7 +248,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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( ActionListEvents.ComputeForMessage( @@ -296,7 +296,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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( ActionListEvents.ComputeForMessage( @@ -344,7 +344,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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( ActionListEvents.ComputeForMessage( @@ -391,7 +391,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( ActionListEvents.ComputeForMessage( @@ -439,7 +439,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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( ActionListEvents.ComputeForMessage( @@ -486,7 +486,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( ActionListEvents.ComputeForMessage( @@ -804,7 +804,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( ActionListEvents.ComputeForMessage( @@ -852,7 +852,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( ActionListEvents.ComputeForMessage( @@ -907,7 +907,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( ActionListEvents.ComputeForMessage( @@ -956,7 +956,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( 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( isMine = true, @@ -1006,7 +1006,7 @@ class ActionListPresenterTest { eventId = null, isMine = true, 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( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index c52f01949d..a8dbab2cf2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -38,7 +38,7 @@ internal fun aMessageEvent( isMine: Boolean = true, isEditable: 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, isThreaded: Boolean = false, sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 8328624bf6..2f80c52369 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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.messagecomposer.suggestions.SuggestionsProcessor 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.TextPillificationHelper 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.aRoomMember 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.mediapickers.api.PickerProvider 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.test.InMemorySessionPreferencesStore 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.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @@ -1535,8 +1536,11 @@ class MessageComposerPresenterTest { permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkParser: PermalinkParser = FakePermalinkParser(), - mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser), - roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), + mentionSpanProvider: MentionSpanProvider = MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = FakeMentionSpanFormatter(), + mentionSpanTheme = MentionSpanTheme(A_USER_ID) + ), textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), isRichTextEditorEnabled: Boolean = true, draftService: ComposerDraftService = FakeComposerDraftService(), @@ -1562,7 +1566,6 @@ class MessageComposerPresenterTest { draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, - roomMemberProfilesCache = roomMemberProfilesCache, suggestionsProcessor = SuggestionsProcessor(), ).apply { isTesting = true diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt index 2f4740dfc9..a5f3417d5d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt @@ -11,9 +11,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.junit4.createComposeRule 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.permalink.FakePermalinkParser import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -23,10 +25,16 @@ import org.robolectric.RobolectricTestRunner class DefaultHtmlConverterProviderTest { @get:Rule val composeTestRule = createComposeRule() + private val provider = DefaultHtmlConverterProvider( + mentionSpanProvider = MentionSpanProvider( + permalinkParser = FakePermalinkParser(), + mentionSpanFormatter = FakeMentionSpanFormatter(), + mentionSpanTheme = MentionSpanTheme(A_USER_ID) + ) + ) + @Test fun `calling provide without calling Update first should throw an exception`() { - val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())) - val exception = runCatching { provider.provide() }.exceptionOrNull() assertThat(exception).isInstanceOf(IllegalStateException::class.java) @@ -34,13 +42,11 @@ class DefaultHtmlConverterProviderTest { @Test fun `calling provide after calling Update first should return an HtmlConverter`() { - val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())) composeTestRule.setContent { CompositionLocalProvider(LocalInspectionMode provides true) { - provider.Update(currentUserId = A_USER_ID) + provider.Update() } } - val htmlConverter = runCatching { provider.provide() }.getOrNull() assertThat(htmlConverter).isNotNull() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt index fd081ac489..ee52aff578 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.timeline.components.event import android.text.SpannableString +import android.text.SpannedString import androidx.activity.ComponentActivity import androidx.compose.runtime.CompositionLocalProvider 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 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.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_2 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.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.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.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.wysiwyg.view.spans.CustomMentionSpan import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest @@ -40,101 +47,100 @@ import org.junit.runner.RunWith class TimelineTextViewTest { @get:Rule val rule = createAndroidComposeRule() + private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID) + private val formatLambda = lambdaRecorder { mentionType -> mentionType.toString() } + private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda) + @Test fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest { val charSequence = "Hello @alice:example.com" - - val result = rule.getText(aTextContentWithFormattedBody(charSequence)) + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) assertThat(result.getMentionSpans()).isEmpty() + assert(formatLambda).isNeverCalled() } @Test fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest { val charSequence = SpannableString("Hello @alice:example.com") - - val result = rule.getText(aTextContentWithFormattedBody(charSequence)) + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) assertThat(result.getMentionSpans()).isEmpty() + assert(formatLambda).isNeverCalled() } @Test fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest { val charSequence = "Hello @alice:example.com" - - val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null)) + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null)) assertThat(result.getMentionSpans()).isEmpty() assertThat(result.toString()).isEqualTo(charSequence) + assert(formatLambda).isNeverCalled() } @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 { append("Hello ") - inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) { + inSpans(MentionSpan(mentionType)) { append(A_ROOM_ID.value) } } + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) - val result = rule.getText(aTextContentWithFormattedBody(charSequence)) - - assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty() + val expectedDisplayText = mentionType.toString() + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) assertThat(result).isEqualTo(charSequence) + assert(formatLambda).isCalledOnce() } @Test fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest { + val mentionType = MentionType.User(userId = A_USER_ID) val charSequence = buildSpannedString { append("Hello ") - inSpans(aMentionSpan(rawValue = A_USER_ID.value)) { + inSpans(MentionSpan(mentionType)) { append("@NotAlice") } } + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) - val result = rule.getText(aTextContentWithFormattedBody(charSequence)) - - assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice") + val expectedDisplayText = mentionType.toString() + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) + assert(formatLambda).isCalledOnce() } @Test fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest { + val mentionType = MentionType.User(userId = A_USER_ID) val charSequence = buildSpannedString { append("Hello ") - inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) { + inSpans(CustomMentionSpan(MentionSpan(mentionType))) { append("@NotAlice") } } - - val result = rule.getText(aTextContentWithFormattedBody(charSequence)) - - assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice") - } - - @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) + val mentionSpanUpdater = aMentionSpanUpdater() + val expectedDisplayText = mentionType.toString() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) + assert(formatLambda).isCalledOnce() } private suspend fun AndroidComposeTestRule.getText( + mentionSpanUpdater: MentionSpanUpdater, content: TimelineItemTextBasedContent, ): CharSequence { val completable = CompletableDeferred() setContent { - val roomMemberProfilesCache = RoomMemberProfilesCache().apply { - replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME))) - } CompositionLocalProvider( - LocalRoomMemberProfilesCache provides roomMemberProfilesCache, + LocalMentionSpanUpdater provides mentionSpanUpdater ) { completable.complete(getTextWithResolvedMentions(content = content)) } @@ -142,21 +148,20 @@ class TimelineTextViewTest { return completable.await() } - private fun aMentionSpan( - rawValue: String, - text: String = "", - type: MentionSpan.Type = MentionSpan.Type.USER - ) = MentionSpan( - text = text, - rawValue = rawValue, - type = type, - ) + private fun aMentionSpanUpdater(): MentionSpanUpdater { + return DefaultMentionSpanUpdater( + formatter = mentionSpanFormatter, + theme = mentionSpanTheme, + roomMemberProfilesCache = RoomMemberProfilesCache(), + roomNamesCache = RoomNamesCache(), + ) + } private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") = TimelineItemTextContent( body = body, htmlDocument = null, - formattedBody = formattedBody, + formattedBody = formattedBody ?: SpannedString(body), isEdited = false ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 51f3f09268..c6c8c7c1e3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -89,9 +89,8 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemTextContent( body = "body", htmlDocument = null, - plainText = "body", isEdited = false, - formattedBody = null, + formattedBody = SpannedString("body"), ) assertThat(result).isEqualTo(expected) } @@ -123,9 +122,8 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemTextContent( body = "body", htmlDocument = null, - plainText = "body", isEdited = false, - formattedBody = null, + formattedBody = "body", ) assertThat(result).isEqualTo(expected) } @@ -141,10 +139,8 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemTextContent( body = "body", htmlDocument = null, - plainText = "body", isEdited = false, - formattedBody = null, - pillifiedBody = SpannableString("body"), + formattedBody = SpannedString("body"), ) assertThat(result).isEqualTo(expected) } @@ -160,7 +156,6 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemTextContent( body = "https://www.example.org", htmlDocument = null, - plainText = "https://www.example.org", isEdited = false, formattedBody = buildSpannedString { inSpans(URLSpan("https://www.example.org")) { @@ -223,7 +218,7 @@ class TimelineItemContentMessageFactoryTest { senderDisambiguatedDisplayName = "Bob", eventId = AN_EVENT_ID, ) - assertThat((result as TimelineItemTextContent).formattedBody).isNull() + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) } @Test @@ -637,8 +632,7 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemNoticeContent( body = "body", htmlDocument = null, - plainText = "body", - formattedBody = null, + formattedBody = SpannedString("body"), isEdited = false, ) assertThat(result).isEqualTo(expected) @@ -671,8 +665,7 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemEmoteContent( body = "* Bob body", htmlDocument = null, - plainText = "* Bob body", - formattedBody = null, + formattedBody = SpannedString("* Bob body"), isEdited = false, ) assertThat(result).isEqualTo(expected) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt index 013ce22cc2..0d571a2459 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt @@ -10,17 +10,21 @@ package io.element.android.features.messages.impl.utils import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 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.RoomId 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.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.FakePermalinkParser -import io.element.android.libraries.matrix.test.room.aRoomMember -import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache -import io.element.android.libraries.textcomposer.mentions.MentionSpan +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 import io.element.android.libraries.textcomposer.mentions.getMentionSpans import org.junit.Test import org.junit.runner.RunWith @@ -30,90 +34,201 @@ class DefaultTextPillificationHelperTest { @Test fun `pillify - adds pills for user ids`() { val text = "A @user:server.com" + val formatter = FakeMentionSpanFormatter() + val userId = UserId("@user:server.com") val helper = aTextPillificationHelper( - permalinkparser = FakePermalinkParser(result = { - PermalinkData.UserLink(UserId("@user:server.com")) + permalinkParser = FakePermalinkParser(result = { + PermalinkData.UserLink(userId) }), permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/@user:server.com") }), + mentionSpanFormatter = formatter, ) 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("@user:server.com") - } - - @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") + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java) + val userType = mentionSpan.type as MentionType.User + assertThat(userType.userId).isEqualTo(userId) + val formatted = formatter.formatDisplayText(MentionType.User(userId)) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) } @Test fun `pillify - adds pills for room aliases`() { val text = "A #room:server.com" + val roomAlias = RoomAlias("#room:server.com") + val formatter = FakeMentionSpanFormatter() val helper = aTextPillificationHelper( - permalinkparser = FakePermalinkParser(result = { - PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com"))) + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias)) }), permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/#room:server.com") }), + mentionSpanFormatter = formatter, ) val pillified = helper.pillify(text) val mentionSpans = pillified.getMentionSpans() assertThat(mentionSpans).hasSize(1) - val mentionSpan = mentionSpans.firstOrNull() - assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM) - assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com") - assertThat(mentionSpan?.text).isEqualTo("#room:server.com") + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java) + val roomType = mentionSpan.type as MentionType.Room + assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias()) + val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias())) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) } @Test fun `pillify - adds pills for @room mentions`() { val text = "An @room mention" - val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = { - PermalinkData.FallbackLink(Uri.EMPTY) - })) + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.FallbackLink(Uri.EMPTY) + }), + mentionSpanFormatter = formatter, + ) val pillified = helper.pillify(text) val mentionSpans = pillified.getMentionSpans() assertThat(mentionSpans).hasSize(1) - val mentionSpan = mentionSpans.firstOrNull() - assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE) - assertThat(mentionSpan?.rawValue).isEqualTo("@room") - assertThat(mentionSpan?.text).isEqualTo("@room") + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone) + val formatted = formatter.formatDisplayText(MentionType.Everyone) + 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( - permalinkparser: PermalinkParser = FakePermalinkParser(), + permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(), - mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser), - roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), - ) = DefaultTextPillificationHelper( - mentionSpanProvider = mentionSpanProvider, - permalinkBuilder = permalinkBuilder, - permalinkParser = permalinkparser, - roomMemberProfilesCache = roomMemberProfilesCache, - ) + mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(), + ): TextPillificationHelper { + val mentionSpanProvider = MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = mentionSpanFormatter, + mentionSpanTheme = MentionSpanTheme(A_USER_ID), + ) + return DefaultTextPillificationHelper( + mentionSpanProvider = mentionSpanProvider, + permalinkBuilder = permalinkBuilder, + permalinkParser = permalinkParser, + ) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt new file mode 100644 index 0000000000..a8128bf622 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt @@ -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) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt index d8c62bca40..bbc7c7a728 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt @@ -8,9 +8,9 @@ package io.element.android.features.messages.impl.utils class FakeTextPillificationHelper( - private val pillifyLambda: (CharSequence) -> CharSequence = { it } + private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text } ) : TextPillificationHelper { - override fun pillify(text: CharSequence): CharSequence { - return pillifyLambda(text) + override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence { + return pillifyLambda(text, pillifyPermalinks) } } diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt index 38f10071e9..b9ea0d2416 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt @@ -9,14 +9,13 @@ package io.element.android.features.messages.test.timeline import androidx.compose.runtime.Composable 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 class FakeHtmlConverterProvider( private val transform: (String) -> CharSequence = { it }, ) : HtmlConverterProvider { @Composable - override fun Update(currentUserId: UserId) = Unit + override fun Update() = Unit override fun provide(): HtmlConverter { return object : HtmlConverter { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt index 487b898db3..c00ab8f113 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -12,13 +12,13 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.tests.testutils.lambda.lambdaError class FakePermalinkParser( - private var result: () -> PermalinkData = { lambdaError() } + private var result: (String) -> PermalinkData = { lambdaError() } ) : PermalinkParser { fun givenResult(result: PermalinkData) { this.result = { result } } override fun parse(uriString: String): PermalinkData { - return result() + return result(uriString) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt index c71e5679cb..0c02a24a0e 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt @@ -7,32 +7,26 @@ 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.SingleIn import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.runningFold import javax.inject.Inject @SingleIn(RoomScope::class) class RoomMemberProfilesCache @Inject constructor() { private val cache = MutableStateFlow(mapOf()) + val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 } - private val _lastCacheUpdate = MutableStateFlow(0L) - val lastCacheUpdate: StateFlow = _lastCacheUpdate - - fun replace(items: List) { + suspend fun replace(items: List) = coroutineScope { cache.value = items.associateBy { it.userId } - _lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1) } fun getDisplayName(userId: UserId): String? { return cache.value[userId]?.disambiguatedDisplayName } } - -val LocalRoomMemberProfilesCache = staticCompositionLocalOf { - RoomMemberProfilesCache() -} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt new file mode 100644 index 0000000000..598b7c28da --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt @@ -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()) + val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 } + + suspend fun replace(items: List) = coroutineScope { + val roomNamesByRoomIdOrAlias = LinkedHashMap(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] + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 1e74160e81..b2adb257d1 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -106,6 +106,7 @@ fun TextComposer( onReceiveSuggestion: (Suggestion?) -> Unit, onSelectRichContent: ((Uri) -> Unit)?, resolveMentionDisplay: (text: String, url: String) -> TextDisplay, + resolveAtRoomMentionDisplay: () -> TextDisplay, modifier: Modifier = Modifier, showTextFormatting: Boolean = false, subcomposing: Boolean = false, @@ -176,7 +177,7 @@ fun TextComposer( composerMode = composerMode, onResetComposerMode = onResetComposerMode, resolveMentionDisplay = resolveMentionDisplay, - resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, + resolveRoomMentionDisplay = resolveAtRoomMentionDisplay, onError = onError, onTyping = onTyping, onSelectRichContent = onSelectRichContent, @@ -930,6 +931,7 @@ private fun ATextComposer( onTyping = {}, onReceiveSuggestion = {}, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, onSelectRichContent = null, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index ee0ec181ca..81b2aab287 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -30,9 +30,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle -import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.MentionSpan -import io.element.android.libraries.textcomposer.mentions.updateMentionStyles import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -75,7 +74,7 @@ fun MarkdownTextInput( } } - val mentionSpanTheme = LocalMentionSpanTheme.current + val mentionSpanUpdater = LocalMentionSpanUpdater.current AndroidView( modifier = Modifier @@ -124,10 +123,9 @@ fun MarkdownTextInput( }, update = { editText -> editText.applyStyleInCompose(richTextEditorStyle) - + val text = state.text.value() + mentionSpanUpdater.updateMentionSpans(text) if (state.text.needsDisplaying()) { - val text = state.text.value() - mentionSpanTheme.updateMentionStyles(text) editText.updateEditableText(text) if (canUpdateState) { state.text.update(editText.editableText, false) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 2f55b96c9f..76587d6e12 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -11,119 +11,153 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface +import android.text.TextPaint +import android.text.TextUtils import android.text.style.ReplacementSpan import androidx.core.text.getSpans -import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.wysiwyg.view.spans.CustomMentionSpan -import kotlin.math.min import kotlin.math.roundToInt +/** + * A span that represents a mention (user, room, etc.) in text. + * @param type The type of mention this span represents. + */ class MentionSpan( - text: String, - val rawValue: String, - val type: Type, + val type: MentionType, ) : ReplacementSpan() { - companion object { - private const val MAX_LENGTH = 20 - } + private val backgroundPaint = Paint() + private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) - var backgroundColor: Int = 0 - var textColor: Int = 0 - var startPadding: Int = 0 - var endPadding: Int = 0 - var typeface: Typeface = Typeface.DEFAULT + private var backgroundColor: Int = 0 + private var textColor: Int = 0 + private var startPadding: Int = 0 + private var endPadding: Int = 0 + private var typeface: Typeface = Typeface.DEFAULT - private var textWidth = 0 - private val backgroundPaint = Paint().apply { - isAntiAlias = true - color = backgroundColor - } + private var measuredTextWidth = 0 - var text: String = text - set(value) { - field = value - mentionText = getActualText(text) + // The formatted display text, will be set by the formatter + var displayText: CharSequence = "" + private set + + /** + * Updates the visual properties of this span. + */ + fun updateTheme(mentionSpanTheme: MentionSpanTheme) { + val isCurrentUser = when (type) { + is MentionType.User -> type.userId == mentionSpanTheme.currentUserId + else -> false } - private var mentionText: CharSequence = getActualText(text) - - fun update(mentionSpanTheme: MentionSpanTheme) { - val isCurrentUser = rawValue == mentionSpanTheme.currentUserId?.value backgroundColor = when (type) { - Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor - Type.ROOM -> mentionSpanTheme.otherBackgroundColor - Type.EVERYONE -> mentionSpanTheme.currentUserBackgroundColor + is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor + is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor + is MentionType.Room -> mentionSpanTheme.otherBackgroundColor + is MentionType.Message -> mentionSpanTheme.otherBackgroundColor } + textColor = when (type) { - Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor - Type.ROOM -> mentionSpanTheme.otherTextColor - Type.EVERYONE -> mentionSpanTheme.currentUserTextColor + is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor + is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor + is MentionType.Room -> mentionSpanTheme.otherTextColor + is MentionType.Message -> mentionSpanTheme.otherTextColor } - backgroundPaint.color = backgroundColor + val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value startPadding = startPaddingPx endPadding = endPaddingPx typeface = mentionSpanTheme.typeface.value } - override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { - paint.typeface = typeface - textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() - return textWidth + startPadding + endPadding + /** + * Updates the display text using a formatter. + */ + fun updateDisplayText(formatter: MentionSpanFormatter) { + displayText = formatter.formatDisplayText(type) } - override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + textPaint.set(paint) + textPaint.typeface = typeface + // Measure the full text width without truncation + measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt() + return measuredTextWidth + startPadding + endPadding + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { // Extra vertical space to add below the baseline (y). This helps us center the span vertically val extraVerticalSpace = y + paint.ascent() + paint.descent() - top - val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) + val availableWidth = (canvas.width - x).coerceAtLeast(0f) + val measuredWidth = measuredTextWidth + startPadding + endPadding + val pillWidth = minOf(availableWidth, measuredWidth.toFloat()) + + backgroundPaint.color = backgroundColor + val rect = RectF(x, top.toFloat(), x + pillWidth, y.toFloat() + extraVerticalSpace) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) - paint.color = textColor - paint.typeface = typeface - canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint) - } - private fun getActualText(text: String): CharSequence { - return buildString { - val mentionText = text.orEmpty() - when (type) { - Type.USER -> { - if (text.firstOrNull() != '@') { - append("@") - } - } - Type.ROOM -> { - if (text.firstOrNull() != '#') { - append("#") - } - } - Type.EVERYONE -> Unit - } - append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH))) - if (mentionText.length > MAX_LENGTH) { - append("…") - } + textPaint.set(paint) + textPaint.color = textColor + textPaint.typeface = typeface + + val availableWidthForText = availableWidth - startPadding - endPadding + val textToDraw = if (measuredTextWidth > availableWidthForText) { + TextUtils.ellipsize( + displayText, + textPaint, + availableWidthForText, + TextUtils.TruncateAt.END + ) + } else { + displayText } - } - - enum class Type { - USER, - ROOM, - EVERYONE, + canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint) } } -fun CharSequence.getMentionSpans(): List { +/** + * 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 { return if (this is android.text.Spanned) { - val customMentionSpans = getSpans() - if (customMentionSpans.isNotEmpty()) { - // If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them - customMentionSpans.map { it.providedSpan }.filterIsInstance() - } else { - // Otherwise try to get the spans directly - getSpans().toList() - } + // If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them + val customMentionSpans = getSpans(start, end) + .map { it.providedSpan } + .filterIsInstance() + // Collect all direct mention spans + val directMentionSpans = getSpans(start, end) + // Return the union of both + customMentionSpans + directMentionSpans } else { emptyList() } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt new file mode 100644 index 0000000000..f4b5f96bf4 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt @@ -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" + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index 63197ffb9c..a233897254 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -7,46 +7,118 @@ package io.element.android.libraries.textcomposer.mentions -import androidx.compose.runtime.Stable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import javax.inject.Inject -@Stable +private const val EVERYONE_MENTION_TEXT = "@room" + +/** + * Provider for [MentionSpan]s. + */ open class MentionSpanProvider @Inject constructor( private val permalinkParser: PermalinkParser, + private val mentionSpanFormatter: MentionSpanFormatter, + private val mentionSpanTheme: MentionSpanTheme, ) { - fun getMentionSpanFor(text: String, url: String): MentionSpan { + /** + * Creates a mention span from a text and URL. + * + * @param text The text associated with the mention + * @param url The URL associated with the mention + * @return A mention span if the URL can be parsed as a permalink, null otherwise + */ + fun getMentionSpanFor(text: String, url: String): MentionSpan? { val permalinkData = permalinkParser.parse(url) - return when { - permalinkData is PermalinkData.UserLink -> { - MentionSpan( - text = text, - rawValue = permalinkData.userId.toString(), - type = MentionSpan.Type.USER, - ) + return getMentionSpanFor(text, permalinkData) + } + + /** + * Creates a mention span from a text and permalink data. + * + * @param text The text associated with the mention + * @param permalinkData The permalink data associated with the mention + * @return A mention span based on the permalink data, null if the permalink data is not supported + */ + private fun getMentionSpanFor(text: String, permalinkData: PermalinkData): MentionSpan? { + return when (permalinkData) { + is PermalinkData.UserLink -> { + createUserMentionSpan(permalinkData.userId) } - text == "@room" && permalinkData is PermalinkData.FallbackLink -> { - MentionSpan( - text = text, - rawValue = "@room", - type = MentionSpan.Type.EVERYONE, - ) + is PermalinkData.RoomLink -> { + val eventId = permalinkData.eventId + if (eventId != null) { + createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId) + } else { + createRoomMentionSpan(permalinkData.roomIdOrAlias) + } } - permalinkData is PermalinkData.RoomLink -> { - MentionSpan( - text = text, - rawValue = permalinkData.roomIdOrAlias.identifier, - type = MentionSpan.Type.ROOM, - ) - } - else -> { - MentionSpan( - text = text, - rawValue = text, - type = MentionSpan.Type.ROOM, - ) + is PermalinkData.FallbackLink -> { + if (text == EVERYONE_MENTION_TEXT) { + createEveryoneMentionSpan() + } else { + null + } } + else -> null + } + } + + /** + * Create a mention span for a user mention. + * + * @param userId The user ID + * @return A mention span for the user + */ + fun createUserMentionSpan(userId: UserId): MentionSpan { + return MentionSpan(type = MentionType.User(userId = userId)).apply { + updateDisplayText(mentionSpanFormatter) + updateTheme(mentionSpanTheme) + } + } + + /** + * Create a mention span for a room mention. + * + * @param roomIdOrAlias The room ID or alias + * @return A mention span for the room + */ + fun createRoomMentionSpan(roomIdOrAlias: RoomIdOrAlias): MentionSpan { + return MentionSpan(MentionType.Room(roomIdOrAlias)).apply { + updateDisplayText(mentionSpanFormatter) + updateTheme(mentionSpanTheme) + } + } + + /** + * Create a mention span for a message mention. + * + * @param roomIdOrAlias The room ID or alias where the message is located + * @param eventId The event ID of the message + * @return A mention span for the message + */ + fun createMessageMentionSpan( + roomIdOrAlias: RoomIdOrAlias, + eventId: EventId, + ): MentionSpan { + return MentionSpan(type = MentionType.Message(roomIdOrAlias, eventId)).apply { + updateTheme(mentionSpanTheme) + updateDisplayText(mentionSpanFormatter) + } + } + + /** + * Create a mention span for @room (everyone). + * + * @return A mention span for @room + */ + fun createEveryoneMentionSpan(): MentionSpan { + return MentionSpan(type = MentionType.Everyone).apply { + updateTheme(mentionSpanTheme) + updateDisplayText(mentionSpanFormatter) } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt index b81fb45b32..edd5cfe9dd 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt @@ -15,11 +15,9 @@ import android.view.ViewGroup import android.widget.TextView import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -34,6 +32,9 @@ import io.element.android.libraries.designsystem.theme.currentUserMentionPillBac import io.element.android.libraries.designsystem.theme.currentUserMentionPillText import io.element.android.libraries.designsystem.theme.mentionPillBackground import io.element.android.libraries.designsystem.theme.mentionPillText +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias @@ -45,13 +46,14 @@ import javax.inject.Inject /** * Theme used for mention spans. * To make this work, you need to: - * 1. Provide [LocalMentionSpanTheme] in a composable that wraps the ones where you want to use mentions. - * 2. Call [MentionSpanTheme.updateStyles] with the current [UserId] so the colors and sizes are computed. - * 3. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.update] to update the styles of the mention spans. + * 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed. + * 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans. */ @Stable -class MentionSpanTheme @Inject constructor() { - internal var currentUserId: UserId? = null +@SingleIn(SessionScope::class) +class MentionSpanTheme(val currentUserId: UserId) { + @Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId) + internal var currentUserTextColor: Int = 0 internal var currentUserBackgroundColor: Int = Color.WHITE internal var otherTextColor: Int = 0 @@ -66,8 +68,7 @@ class MentionSpanTheme @Inject constructor() { */ @Suppress("ComposableNaming") @Composable - fun updateStyles(currentUserId: UserId) { - this.currentUserId = currentUserId + fun updateStyles() { currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() otherTextColor = ElementTheme.colors.mentionPillText.toArgb() @@ -93,24 +94,28 @@ fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) { val spanned = charSequence as? Spanned ?: return val mentionSpans = spanned.getMentionSpans() for (span in mentionSpans) { - span.update(this) + span.updateTheme(this) } } -/** - * Composition local containing the current [MentionSpanTheme]. - */ -val LocalMentionSpanTheme = staticCompositionLocalOf { - MentionSpanTheme() -} - - @PreviewsDayNight - @Composable - internal fun MentionSpanThemePreview() { +@PreviewsDayNight +@Composable +internal fun MentionSpanThemePreview() { ElementPreview { - val mentionSpanTheme = remember { MentionSpanTheme() } + val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) } val provider = remember { MentionSpanProvider( + mentionSpanTheme = mentionSpanTheme, + mentionSpanFormatter = object : MentionSpanFormatter { + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return when (mentionType) { + is MentionType.User -> mentionType.userId.value + is MentionType.Room -> mentionType.roomIdOrAlias.identifier + is MentionType.Message -> "\uD83D\uDCAC️ > ${mentionType.roomIdOrAlias.identifier}" + is MentionType.Everyone -> "@room" + } + } + }, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData { return when (uriString) { @@ -133,36 +138,31 @@ val LocalMentionSpanTheme = staticCompositionLocalOf { fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") fun mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org") - fun mentionSpanEveryone() = provider.getMentionSpanFor("@room", "@room") - mentionSpanTheme.updateStyles(currentUserId = UserId("@me:matrix.org")) + fun mentionSpanEveryone() = provider.createEveryoneMentionSpan() + mentionSpanTheme.updateStyles() - CompositionLocalProvider( - LocalMentionSpanTheme provides mentionSpanTheme - ) { - AndroidView(factory = { context -> - TextView(context).apply { - includeFontPadding = false - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - text = buildSpannedString { - append("This is a ") - append("@mention", mentionSpanMe(), 0) - append(" to the current user and this is a ") - append("@mention", mentionSpanOther(), 0) - append(" to other user. This is for everyone in the ") - append("@room", mentionSpanEveryone(), 0) - append(". This one is for a link to another room: ") - append("#room:matrix.org", mentionSpanRoom(), 0) - append("\n\n") - append("This ") - append("mention", mentionSpanMe(), 0) - append(" didn't have an '@' and it was automatically added, same as this ") - append("room:matrix.org", mentionSpanRoom(), 0) - append(" one, which had no leading '#'.") - } - mentionSpanTheme.updateMentionStyles(text) - setTextColor(textColor) + AndroidView(factory = { context -> + TextView(context).apply { + includeFontPadding = false + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + text = buildSpannedString { + append("This is a ") + append("@mention", mentionSpanMe(), 0) + append(" to the current user and this is a ") + append("@mention", mentionSpanOther(), 0) + append(" to other user. This is for everyone in the ") + append("@room", mentionSpanEveryone(), 0) + append(". This one is for a link to another room: ") + append("#room:matrix.org", mentionSpanRoom(), 0) + append("\n\n") + append("This ") + append("mention", mentionSpanMe(), 0) + append(" didn't have an '@' and it was automatically added, same as this ") + append("room:matrix.org", mentionSpanRoom(), 0) + append(" one, which had no leading '#'.") } - }) - } + setTextColor(textColor) + } + }) } - } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt new file mode 100644 index 0000000000..94d74a72e3 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt @@ -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 { NoOpMentionSpanUpdater } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index 543fe093d4..8ee7696e2b 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -21,14 +21,13 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.core.text.getSpans -import io.element.android.libraries.matrix.api.core.RoomAlias -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence -import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionType import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.getMentionSpans import kotlinx.parcelize.Parcelize @@ -48,39 +47,33 @@ class MarkdownTextEditorState( fun insertSuggestion( resolvedSuggestion: ResolvedSuggestion, mentionSpanProvider: MentionSpanProvider, - permalinkBuilder: PermalinkBuilder, ) { val suggestion = currentSuggestion ?: return when (resolvedSuggestion) { is ResolvedSuggestion.AtRoom -> { val currentText = SpannableStringBuilder(text.value()) - val replaceText = "@room" - val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 - currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text.update(currentText, true) selection = IntRange(end + 1, end + 1) } is ResolvedSuggestion.Member -> { val currentText = SpannableStringBuilder(text.value()) - val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return - val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId) currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 - currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) this.text.update(currentText, true) this.selection = IntRange(end + 1, end + 1) } is ResolvedSuggestion.Alias -> { val currentText = SpannableStringBuilder(text.value()) - val text = resolvedSuggestion.roomAlias.value - val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return - val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias()) currentText.replace(suggestion.start, suggestion.end, "# ") val end = suggestion.start + 1 - currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) this.text.update(currentText, true) this.selection = IntRange(end + 1, end + 1) } @@ -98,19 +91,23 @@ class MarkdownTextEditorState( val start = charSequence.getSpanStart(mention) val end = charSequence.getSpanEnd(mention) when (mention.type) { - MentionSpan.Type.USER -> { - permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link -> - replace(start, end, "[${mention.rawValue}]($link)") + is MentionType.User -> { + permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link -> + replace(start, end, "[${mention.type.userId}]($link)") } } - MentionSpan.Type.EVERYONE -> { + is MentionType.Everyone -> { replace(start, end, "@room") } - MentionSpan.Type.ROOM -> { - permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link -> - replace(start, end, "[${mention.text}]($link)") + is MentionType.Room -> { + val roomIdOrAlias = mention.type.roomIdOrAlias + if (roomIdOrAlias is RoomIdOrAlias.Alias) { + permalinkBuilder.permalinkForRoomAlias(roomIdOrAlias.roomAlias).getOrNull()?.let { link -> + replace(start, end, "[${roomIdOrAlias.roomAlias}]($link)") + } } } + else -> Unit } } } @@ -122,12 +119,12 @@ class MarkdownTextEditorState( fun getMentions(): List { val text = SpannableString(text.value()) - val mentionSpans = text.getSpans(0, text.length) + val mentionSpans = text.getMentionSpans() return mentionSpans.mapNotNull { mentionSpan -> when (mentionSpan.type) { - MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue)) - MentionSpan.Type.EVERYONE -> IntentionalMention.Room - MentionSpan.Type.ROOM -> null + is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId) + is MentionType.Everyone -> IntentionalMention.Room + else -> null } } } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index 9fdc4e2246..1176cdadaa 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -16,14 +16,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpan -import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion @@ -146,7 +145,6 @@ class MarkdownTextInputTest { @Test fun `inserting a mention replaces the existing text with a span`() = runTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) - val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") rule.setMarkdownTextInput(state = state) @@ -155,8 +153,7 @@ class MarkdownTextInputTest { editor = it.findEditor() state.insertSuggestion( ResolvedSuggestion.Member(roomMember = aRoomMember()), - MentionSpanProvider(permalinkParser = permalinkParser), - permalinkBuilder, + aMentionSpanProvider(permalinkParser), ) } rule.awaitIdle() diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt index 50ac074078..de4b9bc64a 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt @@ -14,8 +14,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.textcomposer.mentions.MentionSpan -import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionType import io.element.android.tests.testutils.WarmUpRule import org.junit.Rule import org.junit.Test @@ -28,22 +27,22 @@ class IntentionalMentionSpanProviderTest { val warmUpRule = WarmUpRule() private val permalinkParser = FakePermalinkParser() - private val mentionSpanProvider = MentionSpanProvider( - permalinkParser = permalinkParser, - ) + private val mentionSpanProvider = aMentionSpanProvider(permalinkParser) @Test fun `getting mention span for a user returns a MentionSpan of type USER`() { permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID)) val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}") - assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.USER) + assertThat(mentionSpan?.type).isInstanceOf(MentionType.User::class.java) + val userType = mentionSpan?.type as MentionType.User + assertThat(userType.userId).isEqualTo(A_USER_ID) } @Test fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() { permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") - assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE) + assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone) } @Test @@ -54,6 +53,8 @@ class IntentionalMentionSpanProviderTest { ) ) val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") - assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.ROOM) + assertThat(mentionSpan?.type).isInstanceOf(MentionType.Room::class.java) + val roomType = mentionSpan?.type as MentionType.Room + assertThat(roomType.roomIdOrAlias).isEqualTo(RoomAlias("#room:matrix.org").toRoomIdOrAlias()) } } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt new file mode 100644 index 0000000000..2d0156a3df --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt @@ -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") + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt new file mode 100644 index 0000000000..cc40a56bf4 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt @@ -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, + ) +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt index 33156add6f..2c7905e2da 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -12,6 +12,8 @@ import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.IntentionalMention @@ -20,8 +22,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpan -import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionType import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -35,9 +38,8 @@ class MarkdownTextEditorStateTest { fun `insertMention - room alias - getMentions return empty list`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val suggestion = aRoomAliasSuggestion() - val permalinkBuilder = FakePermalinkBuilder() val mentionSpanProvider = aMentionSpanProvider() - state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(suggestion, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() } @@ -48,9 +50,8 @@ class MarkdownTextEditorStateTest { } val suggestion = aRoomAliasSuggestion() val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) - val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(suggestion, mentionSpanProvider) } @Test @@ -60,9 +61,8 @@ class MarkdownTextEditorStateTest { } val suggestion = aRoomAliasSuggestion() val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) - val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(suggestion, mentionSpanProvider) } @Test @@ -70,31 +70,11 @@ class MarkdownTextEditorStateTest { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val member = aRoomMember() val mention = ResolvedSuggestion.Member(member) - val permalinkBuilder = FakePermalinkBuilder() val mentionSpanProvider = aMentionSpanProvider() - - state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) - + state.insertSuggestion(mention, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() } - @Test - fun `insertSuggestion - with member but failed PermalinkBuilder result`() { - val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") - } - val member = aRoomMember() - val mention = ResolvedSuggestion.Member(member) - val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) - val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) }) - val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - - state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) - - val mentions = state.getMentions() - assertThat(mentions).isEmpty() - } - @Test fun `insertSuggestion - with member`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { @@ -103,10 +83,9 @@ class MarkdownTextEditorStateTest { val member = aRoomMember() val mention = ResolvedSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) - val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() @@ -119,11 +98,10 @@ class MarkdownTextEditorStateTest { currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } val mention = ResolvedSuggestion.AtRoom - val permalinkBuilder = FakePermalinkBuilder() val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() @@ -177,16 +155,10 @@ class MarkdownTextEditorStateTest { assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) } - private fun aMentionSpanProvider( - permalinkParser: FakePermalinkParser = FakePermalinkParser(), - ): MentionSpanProvider { - return MentionSpanProvider(permalinkParser) - } - private fun aMarkdownTextWithMentions(): CharSequence { - val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER) - val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE) - val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM) + val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org"))) + val atRoomMentionSpan = MentionSpan(MentionType.Everyone) + val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias())) return buildSpannedString { append("Hello ") inSpans(userMentionSpan) { diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png index 5f6cb5caad..78aa5cff8d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f541f0a05b49272ef3e379a9a629c71458b37435928338a3a58ac852b38eb803 -size 48802 +oid sha256:73a225eeb9c217ada941b5add96dd2f832fb33b702f86eae5e1f5ecbee5a4bb2 +size 51625 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png index 81f4997ffc..20eb03d5d4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a542b3be557dddc3aba16bda8bb7db1bf0078e6bb3abcc42a11c723e8dba3504 -size 46683 +oid sha256:ab365d585b76ab0723c9998cc13f755640b7f086b99b55804a38e103b8d42416 +size 49440 diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index cd27b0be16..71187feced 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -227,7 +227,7 @@ Compose: - LocalMediaItemPresenterFactories - LocalTimelineItemPresenterFactories - LocalRoomMemberProfilesCache - - LocalMentionSpanTheme + - LocalMentionSpanUpdater - LocalAnalyticsService - LocalBuildMeta CompositionLocalNaming: