From 2ff5fa67fc683d6ae93ad65547c4da8362e0b856 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 15 Jul 2024 18:27:59 +0200 Subject: [PATCH] Restore intentional mentions in the markdown/plain text editor (#3193) * Restore intentional mentions in the markdown/plain text editor --------- Co-authored-by: ElementBot --- .../messages/impl/MessagesFlowNode.kt | 13 +- .../MessageComposerPresenter.kt | 39 +++- .../messagecomposer/MessageComposerState.kt | 4 +- .../MessageComposerStateProvider.kt | 7 +- .../messagecomposer/MessageComposerView.kt | 2 +- .../timeline/DefaultHtmlConverterProvider.kt | 20 +- .../components/event/TimelineItemTextView.kt | 6 +- .../impl/utils/TextPillificationHelper.kt | 80 ++++++++ .../messages/impl/MessagesPresenterTest.kt | 7 + .../MessageComposerPresenterTest.kt | 24 ++- .../DefaultHtmlConverterProviderTest.kt | 6 +- .../components/event/TimelineTextViewTest.kt | 4 - .../impl/utils/TextPillificationHelperTest.kt | 128 +++++++++++++ .../matrix/api/core/MatrixPatterns.kt | 57 +++++- .../matrix/api/permalink/PermalinkBuilder.kt | 3 + .../matrix/api/core/MatrixPatternsTest.kt | 98 ++++++++++ .../impl/permalink/DefaultPermalinkBuilder.kt | 11 ++ .../test/permalink/FakePermalinkBuilder.kt | 10 +- .../ui/messages/RoomMemberProfilesCache.kt | 3 + .../libraries/textcomposer/TextComposer.kt | 25 +-- .../components/markdown/MarkdownTextInput.kt | 8 +- .../textcomposer/mentions/MentionSpan.kt | 30 ++- .../mentions/MentionSpanProvider.kt | 154 +--------------- .../textcomposer/mentions/MentionSpanTheme.kt | 172 ++++++++++++++++++ .../model/MarkdownTextEditorState.kt | 11 +- .../markdown/MarkdownTextInputTest.kt | 6 +- .../impl/mentions/MentionSpanProviderTest.kt | 56 ++---- .../impl/model/MarkdownTextEditorStateTest.kt | 15 +- ...er.mentions_MentionSpanTheme_Day_0_en.png} | 0 ....mentions_MentionSpanTheme_Night_0_en.png} | 0 tools/detekt/detekt.yml | 2 +- 31 files changed, 715 insertions(+), 286 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt create mode 100644 libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt rename tests/uitests/src/test/snapshots/images/{libraries.textcomposer.mentions_MentionSpan_Day_0_en.png => libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.textcomposer.mentions_MentionSpan_Night_0_en.png => libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png} (100%) 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 3999a5fe0c..4fd59be6cb 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 @@ -73,8 +73,8 @@ import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCa import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode -import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider -import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList @@ -94,7 +94,7 @@ class MessagesFlowNode @AssistedInject constructor( private val analyticsService: AnalyticsService, private val room: MatrixRoom, private val roomMemberProfilesCache: RoomMemberProfilesCache, - mentionSpanProviderFactory: MentionSpanProvider.Factory, + private val mentionSpanTheme: MentionSpanTheme, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -151,8 +151,6 @@ class MessagesFlowNode @AssistedInject constructor( private val callbacks = plugins() - private val mentionSpanProvider = mentionSpanProviderFactory.create(room.sessionId.value) - override fun onBuilt() { super.onBuilt() @@ -371,11 +369,10 @@ class MessagesFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - mentionSpanProvider.updateStyles() - + mentionSpanTheme.updateStyles(currentUserId = room.sessionId) CompositionLocalProvider( LocalRoomMemberProfilesCache provides roomMemberProfilesCache, - LocalMentionSpanProvider provides mentionSpanProvider, + LocalMentionSpanTheme provides mentionSpanTheme, ) { BackstackWithOverlayBox(modifier) } 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 85eacc38ae..7bd6bd1867 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 @@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.attachments.preview.error.sendA import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage @@ -52,12 +53,14 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.ProgressCallback 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.MatrixRoom import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.draft.ComposerDraft 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.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 @@ -66,7 +69,8 @@ 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.LocalMentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message @@ -77,6 +81,7 @@ import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEdito import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException @@ -116,6 +121,9 @@ class MessageComposerPresenter @Inject constructor( permissionsPresenterFactory: PermissionsPresenter.Factory, private val timelineController: TimelineController, private val draftService: ComposerDraftService, + private val mentionSpanProvider: MentionSpanProvider, + private val pillificationHelper: TextPillificationHelper, + private val roomMemberProfilesCache: RoomMemberProfilesCache, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingEvent: MessageComposerEvents? = null @@ -139,7 +147,6 @@ class MessageComposerPresenter @Inject constructor( richTextEditorState.isReadyToProcessActions = true } val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) - var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) @@ -260,8 +267,6 @@ class MessageComposerPresenter @Inject constructor( } } - val mentionSpanProvider = LocalMentionSpanProvider.current - fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value @@ -386,9 +391,24 @@ class MessageComposerPresenter @Inject constructor( } } + val mentionSpanTheme = LocalMentionSpanTheme.current + val resolveMentionDisplay = remember(mentionSpanTheme) { + { 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) + TextDisplay.Custom(mentionSpan) + } else { + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) + mentionSpan.update(mentionSpanTheme) + TextDisplay.Custom(mentionSpan) + } + } + } return MessageComposerState( textEditorState = textEditorState, - permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, showAttachmentSourcePicker = showAttachmentSourcePicker, @@ -397,7 +417,8 @@ class MessageComposerPresenter @Inject constructor( canCreatePoll = canCreatePoll.value, attachmentsState = attachmentsState.value, memberSuggestions = memberSuggestions.toPersistentList(), - eventSink = { handleEvents(it) } + resolveMentionDisplay = resolveMentionDisplay, + eventSink = { handleEvents(it) }, ) } @@ -627,7 +648,8 @@ class MessageComposerPresenter @Inject constructor( analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) } else { val markdown = richTextEditorState.messageMarkdown - markdownTextEditorState.text.update(markdown, true) + val pilliefiedMarkdown = pillificationHelper.pillify(markdown) + markdownTextEditorState.text.update(pilliefiedMarkdown, true) // Give some time for the focus of the previous editor to be cleared delay(100) markdownTextEditorState.requestFocusAction() @@ -688,7 +710,8 @@ class MessageComposerPresenter @Inject constructor( if (content.isEmpty()) { markdownTextEditorState.selection = IntRange.EMPTY } - markdownTextEditorState.text.update(content, true) + val pillifiedContent = pillificationHelper.pillify(content) + 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 b1c7ad79b5..332a9e75f8 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 @@ -19,16 +19,15 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.ImmutableList @Stable data class MessageComposerState( val textEditorState: TextEditorState, - val permalinkParser: PermalinkParser, val isFullScreen: Boolean, val mode: MessageComposerMode, val showAttachmentSourcePicker: Boolean, @@ -37,6 +36,7 @@ data class MessageComposerState( val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, val memberSuggestions: ImmutableList, + val resolveMentionDisplay: (String, String) -> 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 31912f21e6..824f87bb8e 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 @@ -17,12 +17,11 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.matrix.api.permalink.PermalinkData -import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -45,9 +44,6 @@ fun aMessageComposerState( memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( textEditorState = textEditorState, - permalinkParser = object : PermalinkParser { - override fun parse(uriString: String): PermalinkData = TODO() - }, isFullScreen = isFullScreen, mode = mode, showTextFormatting = showTextFormatting, @@ -56,5 +52,6 @@ fun aMessageComposerState( canCreatePoll = canCreatePoll, attachmentsState = attachmentsState, memberSuggestions = memberSuggestions, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, 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 a033791f34..6e261e8ac9 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 @@ -107,7 +107,6 @@ internal fun MessageComposerView( modifier = modifier, state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, - permalinkParser = state.permalinkParser, subcomposing = subcomposing, onRequestFocus = ::onRequestFocus, onSendMessage = ::sendMessage, @@ -122,6 +121,7 @@ internal fun MessageComposerView( onSendVoiceMessage = onSendVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage, onReceiveSuggestion = ::onSuggestionReceived, + resolveMentionDisplay = state.resolveMentionDisplay, 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 1987b46053..6eb93756fa 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 @@ -29,7 +29,8 @@ import io.element.android.libraries.di.SessionScope 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.LocalMentionSpanProvider +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 import io.element.android.wysiwyg.display.TextDisplay @@ -39,7 +40,9 @@ import javax.inject.Inject @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) -class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider { +class DefaultHtmlConverterProvider @Inject constructor( + private val mentionSpanProvider: MentionSpanProvider, +) : HtmlConverterProvider { private val htmlConverter: MutableState = mutableStateOf(null) @Composable @@ -50,20 +53,23 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider } val editorStyle = ElementRichTextEditorStyle.textStyle() - val mentionSpanProvider = LocalMentionSpanProvider.current - + val mentionSpanTheme = LocalMentionSpanTheme.current val context = LocalContext.current - htmlConverter.value = remember(editorStyle, mentionSpanProvider) { + htmlConverter.value = remember(editorStyle, mentionSpanTheme) { StyledHtmlConverter( context = context, mentionDisplayHandler = object : MentionDisplayHandler { override fun resolveAtRoomMentionDisplay(): TextDisplay { - return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")) + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#") + mentionSpan.update(mentionSpanTheme) + return TextDisplay.Custom(mentionSpan) } override fun resolveMentionDisplay(text: String, url: String): TextDisplay { - return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) + mentionSpan.update(mentionSpanTheme) + return TextDisplay.Custom(mentionSpan) } }, isMention = { _, url -> mentionDetector?.isMention(url).orFalse() } 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 b9e441f84e..a04d524d43 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 @@ -41,8 +41,10 @@ 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.wysiwyg.compose.EditorStyledText @Composable @@ -74,9 +76,11 @@ fun TimelineItemTextView( internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence { val userProfileCache = LocalRoomMemberProfilesCache.current val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState() - val formattedBody = remember(content.formattedBody, lastCacheUpdate) { + val mentionSpanTheme = LocalMentionSpanTheme.current + val formattedBody = remember(content.formattedBody, mentionSpanTheme, lastCacheUpdate) { content.formattedBody?.let { formattedBody -> updateMentionSpans(formattedBody, userProfileCache) + mentionSpanTheme.updateMentionStyles(formattedBody) formattedBody } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt new file mode 100644 index 0000000000..ad4e6aa64f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.utils + +import android.text.Spannable +import android.text.SpannableStringBuilder +import androidx.core.text.getSpans +import io.element.android.libraries.matrix.api.core.MatrixPatternType +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomAlias +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.PermalinkParser +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import javax.inject.Inject + +class TextPillificationHelper @Inject constructor( + private val mentionSpanProvider: MentionSpanProvider, + private val permalinkBuilder: PermalinkBuilder, + private val permalinkParser: PermalinkParser, + private val roomMemberProfilesCache: RoomMemberProfilesCache, +) { + @Suppress("LoopWithTooManyJumpStatements") + fun pillify(text: CharSequence): CharSequence { + val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end } + if (matches.isEmpty()) return text + + val spannable = SpannableStringBuilder(text) + for (match in matches) { + when (match.type) { + MatrixPatternType.USER_ID -> { + 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) + } + } + 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) + } + } + 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) + } + } + else -> Unit + } + } + return spannable + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 74cffb30fd..906b8d4ac1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -44,6 +44,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.typing.TypingNotificationPresenter +import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager @@ -82,6 +83,7 @@ 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.test.FakePickerProvider import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer @@ -93,6 +95,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService @@ -765,6 +768,7 @@ class MessagesPresenterTest { val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val appPreferencesStore = InMemoryAppPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore() + val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()) val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, room = matrixRoom, @@ -782,6 +786,9 @@ class MessagesPresenterTest { permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), draftService = FakeComposerDraftService(), + mentionSpanProvider = mentionSpanProvider, + pillificationHelper = TextPillificationHelper(mentionSpanProvider, FakePermalinkBuilder(), FakePermalinkParser(), RoomMemberProfilesCache()), + roomMemberProfilesCache = RoomMemberProfilesCache(), ).apply { showTextFormatting = true isTesting = true diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 667d43b56e..c7ebab4c82 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.Mention @@ -69,6 +71,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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 +85,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory 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.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @@ -318,7 +322,7 @@ class MessageComposerPresenterTest { @Test fun `present - send message with plain text enabled`() = runTest { - val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") }) val presenter = createPresenter(this, isRichTextEditorEnabled = false) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() @@ -934,11 +938,11 @@ class MessageComposerPresenterTest { } @Test - fun `present - insertMention`() = runTest { + fun `present - insertMention for user in rich text editor`() = runTest { val presenter = createPresenter( coroutineScope = this, permalinkBuilder = FakePermalinkBuilder( - result = { + permalinkForUserLambda = { Result.success("https://matrix.to/#/${A_USER_ID_2.value}") } ) @@ -1355,6 +1359,15 @@ class MessageComposerPresenterTest { snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser), + roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), + textPillificationHelper: TextPillificationHelper = TextPillificationHelper( + mentionSpanProvider = mentionSpanProvider, + permalinkBuilder = permalinkBuilder, + permalinkParser = permalinkParser, + roomMemberProfilesCache = roomMemberProfilesCache, + ), isRichTextEditorEnabled: Boolean = true, draftService: ComposerDraftService = FakeComposerDraftService(), ) = MessageComposerPresenter( @@ -1370,10 +1383,13 @@ class MessageComposerPresenterTest { DefaultMessageComposerContext(), TestRichTextEditorStateFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), - permalinkParser = FakePermalinkParser(), + permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), draftService = draftService, + mentionSpanProvider = mentionSpanProvider, + pillificationHelper = textPillificationHelper, + roomMemberProfilesCache = roomMemberProfilesCache, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled 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 276544a057..d40f81a10c 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 @@ -21,6 +21,8 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.junit4.createComposeRule import com.google.common.truth.Truth.assertThat 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 org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -32,7 +34,7 @@ class DefaultHtmlConverterProviderTest { @Test fun `calling provide without calling Update first should throw an exception`() { - val provider = DefaultHtmlConverterProvider() + val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())) val exception = runCatching { provider.provide() }.exceptionOrNull() @@ -41,7 +43,7 @@ class DefaultHtmlConverterProviderTest { @Test fun `calling provide after calling Update first should return an HtmlConverter`() { - val provider = DefaultHtmlConverterProvider() + val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())) composeTestRule.setContent { CompositionLocalProvider(LocalInspectionMode provides true) { provider.Update(currentUserId = A_USER_ID) 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 1a08cfd750..bf8c1d4ad1 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 @@ -159,10 +159,6 @@ class TimelineTextViewTest { text = text, rawValue = rawValue, type = type, - backgroundColor = 0, - textColor = 0, - startPadding = 0, - endPadding = 0, ) private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") = diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt new file mode 100644 index 0000000000..4cc695c1e8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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.RoomAlias +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 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.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.getMentionSpans +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TextPillificationHelperTest { + @Test + fun `pillify - adds pills for user ids`() { + 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") + }), + ) + 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") + } + + @Test + fun `pillify - adds pills for room aliases`() { + val text = "A #room:server.com" + val helper = aTextPillificationHelper( + permalinkparser = FakePermalinkParser(result = { + PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com"))) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { + Result.success("https://matrix.to/#/#room:server.com") + }), + ) + 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") + } + + @Test + fun `pillify - adds pills for @room mentions`() { + val text = "An @room mention" + val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = { + PermalinkData.FallbackLink(Uri.EMPTY) + })) + 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") + } + + private fun aTextPillificationHelper( + permalinkparser: PermalinkParser = FakePermalinkParser(), + permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(), + mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser), + roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(), + ) = TextPillificationHelper( + mentionSpanProvider = mentionSpanProvider, + permalinkBuilder = permalinkBuilder, + permalinkParser = permalinkparser, + roomMemberProfilesCache = roomMemberProfilesCache, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt index ac77e58986..20e7746e3f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -16,13 +16,16 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser + /** * This class contains pattern to match the different Matrix ids * Ref: https://matrix.org/docs/spec/appendices#identifier-grammar */ object MatrixPatterns { // Note: TLD is not mandatory (localhost, IP address...) - private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?" + private const val DOMAIN_REGEX = ":[A-Za-z0-9.-]+(:[0-9]{2,5})?" // regex pattern to find matrix user ids in a string. // See https://matrix.org/docs/spec/appendices#historical-user-ids @@ -109,4 +112,56 @@ object MatrixPatterns { * @return true if the string is a valid thread id. */ fun isThreadId(str: String?) = isEventId(str) + + /** + * Finds existing ids or aliases in a [CharSequence]. + * Note not all cases are implemented. + */ + fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List { + val rawTextMatches = "\\S+?$DOMAIN_REGEX".toRegex(RegexOption.IGNORE_CASE).findAll(text) + val urlMatches = "\\[\\S+?\\]\\((\\S+?)\\)".toRegex(RegexOption.IGNORE_CASE).findAll(text) + val atRoomMatches = Regex("@room").findAll(text) + return buildList { + for (match in rawTextMatches) { + // Match existing id and alias patterns in the text + val type = when { + isUserId(match.value) -> MatrixPatternType.USER_ID + isRoomId(match.value) -> MatrixPatternType.ROOM_ID + isRoomAlias(match.value) -> MatrixPatternType.ROOM_ALIAS + isEventId(match.value) -> MatrixPatternType.EVENT_ID + else -> null + } + if (type != null) { + add(MatrixPatternResult(type, match.value, match.range.first, match.range.last + 1)) + } + } + for (match in urlMatches) { + // Extract the link and check if it's a valid permalink + val urlMatch = match.groupValues[1] + when (val permalink = permalinkParser.parse(urlMatch)) { + is PermalinkData.UserLink -> { + add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1)) + } + is PermalinkData.RoomLink -> { + add(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, permalink.roomIdOrAlias.identifier, match.range.first, match.range.last + 1)) + } + else -> Unit + } + } + for (match in atRoomMatches) { + // Special case for `@room` mentions + add(MatrixPatternResult(MatrixPatternType.AT_ROOM, match.value, match.range.first, match.range.last + 1)) + } + } + } +} + +enum class MatrixPatternType { + USER_ID, + ROOM_ID, + ROOM_ALIAS, + EVENT_ID, + AT_ROOM } + +data class MatrixPatternResult(val type: MatrixPatternType, val value: String, val start: Int, val end: Int) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index 76bb327c2e..5e562f43c5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -16,12 +16,15 @@ package io.element.android.libraries.matrix.api.permalink +import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId interface PermalinkBuilder { fun permalinkForUser(userId: UserId): Result + fun permalinkForRoomAlias(roomAlias: RoomAlias): Result } sealed class PermalinkBuilderError : Throwable() { data object InvalidUserId : PermalinkBuilderError() + data object InvalidRoomAlias : PermalinkBuilderError() } diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt new file mode 100644 index 0000000000..d195789d14 --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.core + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import org.junit.Test + +class MatrixPatternsTest { + @Test + fun `findPatterns - returns raw user ids`() { + val text = "A @user:server.com and @user2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.USER_ID, "@user2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw room ids`() { + val text = "A !room:server.com and !room2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw room aliases`() { + val text = "A #room:server.com and #room2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw room event ids`() { + val text = "A \$event:server.com and \$event2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event:server.com", 2, 19), + MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event2:server.com", 24, 42) + ) + } + + @Test + fun `findPatterns - returns @room mention`() { + val text = "A @room mention" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.AT_ROOM, "@room", 2, 7)) + } + + @Test + fun `findPatterns - returns user ids in permalinks`() { + val text = "A [User](https://matrix.to/#/@user:server.com)" + val permalinkParser = aPermalinkParser { _ -> + PermalinkData.UserLink(UserId("@user:server.com")) + } + val patterns = MatrixPatterns.findPatterns(text, permalinkParser) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 46)) + } + + @Test + fun `findPatterns - returns room aliases in permalinks`() { + val text = "A [Room](https://matrix.to/#/#room:server.com)" + val permalinkParser = aPermalinkParser { _ -> + PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com"))) + } + val patterns = MatrixPatterns.findPatterns(text, permalinkParser) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 46)) + } + + private fun aPermalinkParser(block: (String) -> PermalinkData = { PermalinkData.FallbackLink(Uri.EMPTY) }) = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return block(uriString) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt index 3103ff4c1e..30a458b28f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt @@ -19,9 +19,11 @@ package io.element.android.libraries.matrix.impl.permalink import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError +import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink import org.matrix.rustcomponents.sdk.matrixToUserPermalink import javax.inject.Inject @@ -35,4 +37,13 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder { matrixToUserPermalink(userId.value) } } + + override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result { + if (!MatrixPatterns.isRoomAlias(roomAlias.value)) { + return Result.failure(PermalinkBuilderError.InvalidRoomAlias) + } + return runCatching { + matrixToRoomAliasPermalink(roomAlias.value) + } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt index f700c3b6af..3510a362ec 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -16,13 +16,19 @@ package io.element.android.libraries.matrix.test.permalink +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.permalink.PermalinkBuilder class FakePermalinkBuilder( - private val result: (UserId) -> Result = { Result.failure(Exception("Not implemented")) } + private val permalinkForUserLambda: (UserId) -> Result = { Result.failure(Exception("Not implemented")) }, + private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { Result.failure(Exception("Not implemented")) }, ) : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { - return result(userId) + return permalinkForUserLambda(userId) + } + + override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result { + return permalinkForRoomAliasLambda(roomAlias) } } 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 32f82cece1..e2e0327bad 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 @@ -17,12 +17,15 @@ 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject +@SingleIn(RoomScope::class) class RoomMemberProfilesCache @Inject constructor() { private val cache = MutableStateFlow(mapOf()) 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 136cc6eeae..8da508716c 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 @@ -52,9 +52,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId -import io.element.android.libraries.matrix.api.permalink.PermalinkData -import io.element.android.libraries.matrix.api.permalink.PermalinkParser -import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.testtags.TestTags @@ -70,7 +67,6 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape -import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.TextEditorState @@ -90,7 +86,6 @@ import kotlin.time.Duration.Companion.seconds fun TextComposer( state: TextEditorState, voiceMessageState: VoiceMessageState, - permalinkParser: PermalinkParser, composerMode: MessageComposerMode, enableVoiceMessages: Boolean, onRequestFocus: () -> Unit, @@ -106,6 +101,7 @@ fun TextComposer( onTyping: (Boolean) -> Unit, onReceiveSuggestion: (Suggestion?) -> Unit, onSelectRichContent: ((Uri) -> Unit)?, + resolveMentionDisplay: (text: String, url: String) -> TextDisplay, modifier: Modifier = Modifier, showTextFormatting: Boolean = false, subcomposing: Boolean = false, @@ -144,8 +140,6 @@ fun TextComposer( } } - val userProfileCache = LocalRoomMemberProfilesCache.current - val placeholder = if (composerMode.inThread) { stringResource(id = CommonStrings.action_reply_in_thread) } else { @@ -155,23 +149,14 @@ fun TextComposer( is TextEditorState.Rich -> { remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { @Composable { - val mentionSpanProvider = LocalMentionSpanProvider.current TextInput( state = state.richTextEditorState, subcomposing = subcomposing, placeholder = placeholder, composerMode = composerMode, onResetComposerMode = onResetComposerMode, - resolveMentionDisplay = { text, url -> - val permalinkData = permalinkParser.parse(url) - if (permalinkData is PermalinkData.UserLink) { - val displayNameOrId = userProfileCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value - TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)) - } else { - TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) - } - }, - resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, onError = onError, onTyping = onTyping, onSelectRichContent = onSelectRichContent, @@ -709,9 +694,6 @@ private fun ATextComposer( state = state, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, - permalinkParser = object : PermalinkParser { - override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented") - }, composerMode = composerMode, enableVoiceMessages = enableVoiceMessages, onRequestFocus = {}, @@ -726,6 +708,7 @@ private fun ATextComposer( onError = {}, onTyping = {}, onReceiveSuggestion = {}, + resolveMentionDisplay = { _, _ -> 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 8016fa637e..ea31f51585 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 @@ -39,7 +39,9 @@ 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.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 @@ -81,6 +83,8 @@ fun MarkdownTextInput( } } + val mentionSpanTheme = LocalMentionSpanTheme.current + AndroidView( modifier = Modifier .padding(top = 6.dp, bottom = 6.dp) @@ -130,7 +134,9 @@ fun MarkdownTextInput( editText.applyStyleInCompose(richTextEditorStyle) if (state.text.needsDisplaying()) { - editText.updateEditableText(state.text.value()) + 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 286c45684e..a095320e0a 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 @@ -31,16 +31,17 @@ class MentionSpan( text: String, val rawValue: String, val type: Type, - val backgroundColor: Int, - val textColor: Int, - val startPadding: Int, - val endPadding: Int, - val typeface: Typeface = Typeface.DEFAULT, ) : ReplacementSpan() { companion object { private const val MAX_LENGTH = 20 } + var backgroundColor: Int = 0 + var textColor: Int = 0 + var startPadding: Int = 0 + var endPadding: Int = 0 + var typeface: Typeface = Typeface.DEFAULT + private var textWidth = 0 private val backgroundPaint = Paint().apply { isAntiAlias = true @@ -55,6 +56,25 @@ class MentionSpan( 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.otherBackgroundColor + } + textColor = when (type) { + Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor + Type.ROOM -> mentionSpanTheme.otherTextColor + Type.EVERYONE -> 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() 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 4e33a461b8..9ce49a2f1b 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 @@ -16,92 +16,23 @@ package io.element.android.libraries.textcomposer.mentions -import android.graphics.Color -import android.graphics.Typeface -import android.net.Uri -import android.view.ViewGroup -import android.widget.TextView -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable 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 -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.text.buildSpannedString -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.rememberTypeface -import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground -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.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.permalink.PermalinkParser -import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject @Stable -class MentionSpanProvider @AssistedInject constructor( - @Assisted private val currentSessionId: String, +open class MentionSpanProvider @Inject constructor( private val permalinkParser: PermalinkParser, ) { - @AssistedFactory - interface Factory { - fun create(currentSessionId: String): MentionSpanProvider - } - - private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp) - - private val paddingValuesPx = mutableStateOf(0 to 0) - private val typeface = mutableStateOf(Typeface.DEFAULT) - - internal var currentUserTextColor: Int = 0 - internal var currentUserBackgroundColor: Int = Color.WHITE - internal var otherTextColor: Int = 0 - internal var otherBackgroundColor: Int = Color.WHITE - - @Suppress("ComposableNaming") - @Composable - fun updateStyles() { - currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() - currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() - otherTextColor = ElementTheme.colors.mentionPillText.toArgb() - otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb() - - typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value - with(LocalDensity.current) { - val leftPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current).roundToPx() - val rightPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current).roundToPx() - paddingValuesPx.value = leftPadding to rightPadding - } - } - fun getMentionSpanFor(text: String, url: String): MentionSpan { val permalinkData = permalinkParser.parse(url) - val (startPaddingPx, endPaddingPx) = paddingValuesPx.value return when { permalinkData is PermalinkData.UserLink -> { - val isCurrentUser = permalinkData.userId.value == currentSessionId MentionSpan( text = text, rawValue = permalinkData.userId.toString(), type = MentionSpan.Type.USER, - backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor, - textColor = if (isCurrentUser) currentUserTextColor else otherTextColor, - startPadding = startPaddingPx, - endPadding = endPaddingPx, - typeface = typeface.value, ) } text == "@room" && permalinkData is PermalinkData.FallbackLink -> { @@ -109,23 +40,13 @@ class MentionSpanProvider @AssistedInject constructor( text = text, rawValue = "@room", type = MentionSpan.Type.EVERYONE, - backgroundColor = otherBackgroundColor, - textColor = otherTextColor, - startPadding = startPaddingPx, - endPadding = endPaddingPx, - typeface = typeface.value, ) } permalinkData is PermalinkData.RoomLink -> { MentionSpan( text = text, - rawValue = permalinkData.roomIdOrAlias.toString(), + rawValue = permalinkData.roomIdOrAlias.identifier, type = MentionSpan.Type.ROOM, - backgroundColor = otherBackgroundColor, - textColor = otherTextColor, - startPadding = startPaddingPx, - endPadding = endPaddingPx, - typeface = typeface.value, ) } else -> { @@ -133,77 +54,8 @@ class MentionSpanProvider @AssistedInject constructor( text = text, rawValue = text, type = MentionSpan.Type.ROOM, - backgroundColor = otherBackgroundColor, - textColor = otherTextColor, - startPadding = startPaddingPx, - endPadding = endPaddingPx, - typeface = typeface.value, ) } } } } - -@PreviewsDayNight -@Composable -internal fun MentionSpanPreview() { - ElementPreview { - val provider = remember { - MentionSpanProvider( - currentSessionId = "@me:matrix.org", - permalinkParser = object : PermalinkParser { - override fun parse(uriString: String): PermalinkData { - return when (uriString) { - "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org")) - "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org")) - "https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink( - roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), - eventId = null, - viaParameters = persistentListOf(), - ) - else -> throw AssertionError("Unexpected value $uriString") - } - } - }, - ) - } - provider.updateStyles() - - val textColor = ElementTheme.colors.textPrimary.toArgb() - 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", "https://matrix.to/#/#room:matrix.org") - 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 one is for a 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) - } - }) - } -} - -val LocalMentionSpanProvider = staticCompositionLocalOf { - MentionSpanProvider( - currentSessionId = "@dummy:value.org", - permalinkParser = object : PermalinkParser { - override fun parse(uriString: String): PermalinkData { - return PermalinkData.FallbackLink(Uri.EMPTY) - } - }, - ) -} 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 new file mode 100644 index 0000000000..f3befe34cb --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.mentions + +import android.graphics.Color +import android.graphics.Typeface +import android.text.Spanned +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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.buildSpannedString +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.rememberTypeface +import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground +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.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.permalink.PermalinkParser +import kotlinx.collections.immutable.persistentListOf +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. + */ +@Stable +class MentionSpanTheme @Inject constructor() { + internal var currentUserId: UserId? = null + internal var currentUserTextColor: Int = 0 + internal var currentUserBackgroundColor: Int = Color.WHITE + internal var otherTextColor: Int = 0 + internal var otherBackgroundColor: Int = Color.WHITE + + private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp) + internal val paddingValuesPx = mutableStateOf(0 to 0) + internal val typeface = mutableStateOf(Typeface.DEFAULT) + + /** + * Updates the styles of the mention spans based on the [ElementTheme] and [currentUserId]. + */ + @Suppress("ComposableNaming") + @Composable + fun updateStyles(currentUserId: UserId) { + this.currentUserId = currentUserId + currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() + currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() + otherTextColor = ElementTheme.colors.mentionPillText.toArgb() + otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb() + + typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + paddingValuesPx.value = remember(paddingValues, density, layoutDirection) { + with(density) { + val leftPadding = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() + val rightPadding = paddingValues.calculateRightPadding(layoutDirection).roundToPx() + leftPadding to rightPadding + } + } + } +} + +/** + * Updates the styles of the mention spans in the given [CharSequence]. + */ +fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) { + val spanned = charSequence as? Spanned ?: return + val mentionSpans = spanned.getMentionSpans() + for (span in mentionSpans) { + span.update(this) + } +} + +/** + * Composition local containing the current [MentionSpanTheme]. + */ +val LocalMentionSpanTheme = staticCompositionLocalOf { + MentionSpanTheme() +} + + @PreviewsDayNight + @Composable + internal fun MentionSpanThemePreview() { + ElementPreview { + val mentionSpanTheme = remember { MentionSpanTheme() } + val provider = remember { + MentionSpanProvider( + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return when (uriString) { + "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org")) + "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org")) + "https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink( + roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), + eventId = null, + viaParameters = persistentListOf(), + ) + else -> throw AssertionError("Unexpected value $uriString") + } + } + }, + ) + } + + val textColor = ElementTheme.colors.textPrimary.toArgb() + 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", "https://matrix.to/#/#room:matrix.org") + mentionSpanTheme.updateStyles(currentUserId = UserId("@me:matrix.org")) + + 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 one is for a 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) + } + }) + } + } + } 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 0d3bb5fdda..dd03c66366 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 @@ -38,6 +38,7 @@ import io.element.android.libraries.textcomposer.components.markdown.StableCharS import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.getMentionSpans import kotlinx.parcelize.Parcelize @Stable @@ -63,7 +64,7 @@ class MarkdownTextEditorState( val currentText = SpannableStringBuilder(text.value()) val replaceText = "@room" val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") - currentText.replace(suggestion.start, suggestion.end, ". ") + currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text.update(currentText, true) @@ -74,7 +75,7 @@ class MarkdownTextEditorState( val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) - currentText.replace(suggestion.start, suggestion.end, ". ") + currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) this.text.update(currentText, true) @@ -86,11 +87,11 @@ class MarkdownTextEditorState( fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String { val charSequence = text.value() return if (charSequence is Spanned) { - val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) + val mentions = charSequence.getMentionSpans() buildString { append(charSequence.toString()) - if (mentions != null && mentions.isNotEmpty()) { - for (mention in mentions.reversed()) { + if (mentions.isNotEmpty()) { + for (mention in mentions.sortedByDescending { charSequence.getSpanEnd(it) }) { val start = charSequence.getSpanStart(mention) val end = charSequence.getSpanEnd(mention) when (mention.type) { 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 7147235603..c5949a3a41 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 @@ -155,7 +155,7 @@ 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(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") rule.setMarkdownTextInput(state = state) @@ -164,14 +164,14 @@ class MarkdownTextInputTest { editor = it.findEditor() state.insertMention( ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), - MentionSpanProvider(currentSessionId = A_SESSION_ID.value, permalinkParser = permalinkParser), + MentionSpanProvider(permalinkParser = permalinkParser), permalinkBuilder, ) } rule.awaitIdle() // Text is replaced with a placeholder - assertThat(editor?.editableText.toString()).isEqualTo(". ") + assertThat(editor?.editableText.toString()).isEqualTo("@ ") // The placeholder contains a MentionSpan val mentionSpans = editor?.editableText?.getSpans(0, 2).orEmpty() assertThat(mentionSpans).isNotEmpty() diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt index bb53f0a79d..b6522305af 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt @@ -16,15 +16,14 @@ package io.element.android.libraries.textcomposer.impl.mentions -import android.graphics.Color import android.net.Uri 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.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.tests.testutils.WarmUpRule import org.junit.Rule @@ -37,66 +36,33 @@ class MentionSpanProviderTest { @JvmField @Rule val warmUpRule = WarmUpRule() - private val myUserColor = Color.RED - private val otherColor = Color.BLUE - private val currentUserId = A_SESSION_ID - private val permalinkParser = FakePermalinkParser() private val mentionSpanProvider = MentionSpanProvider( - currentSessionId = currentUserId.value, permalinkParser = permalinkParser, - ).apply { - currentUserBackgroundColor = myUserColor - currentUserTextColor = myUserColor - otherBackgroundColor = otherColor - otherTextColor = otherColor - } + ) @Test - fun `getting mention span for current user should return a MentionSpan with custom colors`() { - permalinkParser.givenResult(PermalinkData.UserLink(currentUserId)) - val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}") - assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor) - assertThat(mentionSpan.textColor).isEqualTo(myUserColor) + 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) } @Test - fun `getting mention span for other user should return a MentionSpan with normal colors`() { - permalinkParser.givenResult(PermalinkData.UserLink(UserId("@other:matrix.org"))) - val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org") - assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) - assertThat(mentionSpan.textColor).isEqualTo(otherColor) - } - - @Test - fun `getting mention span for everyone in the room`() { + 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.backgroundColor).isEqualTo(otherColor) - assertThat(mentionSpan.textColor).isEqualTo(otherColor) + assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE) } @Test - fun `getting mention span for a room should return a MentionSpan with normal colors`() { + fun `getting mention span for a room returns a MentionSpan of type ROOM`() { permalinkParser.givenResult( PermalinkData.RoomLink( roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), ) ) val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") - assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) - assertThat(mentionSpan.textColor).isEqualTo(otherColor) - } - - @Test - fun `getting mention span for @room should return a MentionSpan with normal colors`() { - permalinkParser.givenResult( - PermalinkData.RoomLink( - roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), - ) - ) - val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#room:matrix.org") - assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) - assertThat(mentionSpan.textColor).isEqualTo(otherColor) + assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.ROOM) } } 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 02c98da423..b4f3c143bd 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 @@ -21,10 +21,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.SessionId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.Mention -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 @@ -60,7 +58,7 @@ class MarkdownTextEditorStateTest { val member = aRoomMember() val mention = ResolvedMentionSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) - val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertMention(mention, mentionSpanProvider, permalinkBuilder) @@ -77,7 +75,7 @@ class MarkdownTextEditorStateTest { val member = aRoomMember() val mention = ResolvedMentionSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) - val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertMention(mention, mentionSpanProvider, permalinkBuilder) @@ -117,7 +115,7 @@ class MarkdownTextEditorStateTest { @Test fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { val text = "No mentions here" - val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }) val state = MarkdownTextEditorState(initialText = text, initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) @@ -148,15 +146,14 @@ class MarkdownTextEditorStateTest { } private fun aMentionSpanProvider( - currentSessionId: SessionId = A_SESSION_ID, permalinkParser: FakePermalinkParser = FakePermalinkParser(), ): MentionSpanProvider { - return MentionSpanProvider(currentSessionId.value, permalinkParser) + return MentionSpanProvider(permalinkParser) } private fun aMarkdownTextWithMentions(): CharSequence { - val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0) - val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE, 0, 0, 0, 0) + val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER) + val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE) return buildSpannedString { append("Hello ") inSpans(userMentionSpan) { diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 7212a58ce3..194bcd95bd 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -226,7 +226,7 @@ Compose: - LocalCameraPositionState - LocalTimelineItemPresenterFactories - LocalRoomMemberProfilesCache - - LocalMentionSpanProvider + - LocalMentionSpanTheme CompositionLocalNaming: active: true ContentEmitterReturningValues: