Browse Source

Restore intentional mentions in the markdown/plain text editor (#3193)

* Restore intentional mentions in the markdown/plain text editor

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/3200/head
Jorge Martin Espinosa 2 months ago committed by GitHub
parent
commit
2ff5fa67fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
  2. 39
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
  3. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
  4. 7
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
  5. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
  6. 20
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
  7. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
  8. 80
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
  9. 7
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
  10. 24
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
  11. 6
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
  12. 4
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
  13. 128
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt
  14. 57
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
  15. 3
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
  16. 98
      libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt
  17. 11
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
  18. 10
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
  19. 3
      libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt
  20. 25
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  21. 8
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt
  22. 30
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt
  23. 154
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
  24. 172
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt
  25. 11
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt
  26. 6
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt
  27. 56
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
  28. 15
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt
  29. 0
      tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png
  30. 0
      tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png
  31. 2
      tools/detekt/detekt.yml

13
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.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
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.MentionSpanTheme
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -94,7 +94,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomMemberProfilesCache: RoomMemberProfilesCache,
mentionSpanProviderFactory: MentionSpanProvider.Factory, private val mentionSpanTheme: MentionSpanTheme,
) : BaseFlowNode<MessagesFlowNode.NavTarget>( ) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -151,8 +151,6 @@ class MessagesFlowNode @AssistedInject constructor(
private val callbacks = plugins<MessagesEntryPoint.Callback>() private val callbacks = plugins<MessagesEntryPoint.Callback>()
private val mentionSpanProvider = mentionSpanProviderFactory.create(room.sessionId.value)
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
@ -371,11 +369,10 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
mentionSpanProvider.updateStyles() mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
CompositionLocalProvider( CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache, LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
LocalMentionSpanProvider provides mentionSpanProvider, LocalMentionSpanTheme provides mentionSpanTheme,
) { ) {
BackstackWithOverlayBox(modifier) BackstackWithOverlayBox(modifier)
} }

39
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.draft.ComposerDraftService
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor 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.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.Mention
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft 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.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -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.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.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.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message 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.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -116,6 +121,9 @@ class MessageComposerPresenter @Inject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController, private val timelineController: TimelineController,
private val draftService: ComposerDraftService, private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null private var pendingEvent: MessageComposerEvents? = null
@ -139,7 +147,6 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState.isReadyToProcessActions = true richTextEditorState.isReadyToProcessActions = true
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
var isMentionsEnabled by remember { mutableStateOf(false) } var isMentionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
@ -260,8 +267,6 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
val mentionSpanProvider = LocalMentionSpanProvider.current
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value 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( return MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
@ -397,7 +417,8 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), memberSuggestions = memberSuggestions.toPersistentList(),
eventSink = { handleEvents(it) } resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
) )
} }
@ -627,7 +648,8 @@ class MessageComposerPresenter @Inject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else { } else {
val markdown = richTextEditorState.messageMarkdown 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 // Give some time for the focus of the previous editor to be cleared
delay(100) delay(100)
markdownTextEditorState.requestFocusAction() markdownTextEditorState.requestFocusAction()
@ -688,7 +710,8 @@ class MessageComposerPresenter @Inject constructor(
if (content.isEmpty()) { if (content.isEmpty()) {
markdownTextEditorState.selection = IntRange.EMPTY markdownTextEditorState.selection = IntRange.EMPTY
} }
markdownTextEditorState.text.update(content, true) val pillifiedContent = pillificationHelper.pillify(content)
markdownTextEditorState.text.update(pillifiedContent, true)
if (requestFocus) { if (requestFocus) {
markdownTextEditorState.requestFocusAction() markdownTextEditorState.requestFocusAction()
} }

4
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.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment 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.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Stable @Stable
data class MessageComposerState( data class MessageComposerState(
val textEditorState: TextEditorState, val textEditorState: TextEditorState,
val permalinkParser: PermalinkParser,
val isFullScreen: Boolean, val isFullScreen: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,
@ -37,6 +36,7 @@ data class MessageComposerState(
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>, val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) )

7
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 package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -45,9 +44,6 @@ fun aMessageComposerState(
memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(), memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO()
},
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
mode = mode, mode = mode,
showTextFormatting = showTextFormatting, showTextFormatting = showTextFormatting,
@ -56,5 +52,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, memberSuggestions = memberSuggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = {}, eventSink = {},
) )

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt

@ -107,7 +107,6 @@ internal fun MessageComposerView(
modifier = modifier, modifier = modifier,
state = state.textEditorState, state = state.textEditorState,
voiceMessageState = voiceMessageState.voiceMessageState, voiceMessageState = voiceMessageState.voiceMessageState,
permalinkParser = state.permalinkParser,
subcomposing = subcomposing, subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus, onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage, onSendMessage = ::sendMessage,
@ -122,6 +121,7 @@ internal fun MessageComposerView(
onSendVoiceMessage = onSendVoiceMessage, onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived, onReceiveSuggestion = ::onSuggestionReceived,
resolveMentionDisplay = state.resolveMentionDisplay,
onError = ::onError, onError = ::onError,
onTyping = ::onTyping, onTyping = ::onTyping,
onSelectRichContent = ::sendUri, onSelectRichContent = ::sendUri,

20
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.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle 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.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider { class DefaultHtmlConverterProvider @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
@ -50,20 +53,23 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = LocalMentionSpanProvider.current val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanProvider) { htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
StyledHtmlConverter( StyledHtmlConverter(
context = context, context = context,
mentionDisplayHandler = object : MentionDisplayHandler { mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay { 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 { 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() } isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }

6
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.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
@ -74,9 +76,11 @@ fun TimelineItemTextView(
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence { internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current val userProfileCache = LocalRoomMemberProfilesCache.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState() 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 -> content.formattedBody?.let { formattedBody ->
updateMentionSpans(formattedBody, userProfileCache) updateMentionSpans(formattedBody, userProfileCache)
mentionSpanTheme.updateMentionStyles(formattedBody)
formattedBody formattedBody
} }
} }

80
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<MentionSpan>(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<MentionSpan>(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<MentionSpan>(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
}
}

7
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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent 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.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.VoiceMessageComposerPlayer
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager 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.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer 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.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -765,6 +768,7 @@ class MessagesPresenterTest {
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val appPreferencesStore = InMemoryAppPreferencesStore() val appPreferencesStore = InMemoryAppPreferencesStore()
val sessionPreferencesStore = InMemorySessionPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore()
val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
room = matrixRoom, room = matrixRoom,
@ -782,6 +786,9 @@ class MessagesPresenterTest {
permalinkBuilder = FakePermalinkBuilder(), permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom), timelineController = TimelineController(matrixRoom),
draftService = FakeComposerDraftService(), draftService = FakeComposerDraftService(),
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = TextPillificationHelper(mentionSpanProvider, FakePermalinkBuilder(), FakePermalinkParser(), RoomMemberProfilesCache()),
roomMemberProfilesCache = RoomMemberProfilesCache(),
).apply { ).apply {
showTextFormatting = true showTextFormatting = true
isTesting = true isTesting = true

24
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.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState 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.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention 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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
@ -82,6 +85,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -318,7 +322,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - send message with plain text enabled`() = runTest { 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) val presenter = createPresenter(this, isRichTextEditorEnabled = false)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present() val state = presenter.present()
@ -934,11 +938,11 @@ class MessageComposerPresenterTest {
} }
@Test @Test
fun `present - insertMention`() = runTest { fun `present - insertMention for user in rich text editor`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this, coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder( permalinkBuilder = FakePermalinkBuilder(
result = { permalinkForUserLambda = {
Result.success("https://matrix.to/#/${A_USER_ID_2.value}") Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
} }
) )
@ -1355,6 +1359,15 @@ class MessageComposerPresenterTest {
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), 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, isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
@ -1370,10 +1383,13 @@ class MessageComposerPresenterTest {
DefaultMessageComposerContext(), DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(), permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room), timelineController = TimelineController(room),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
roomMemberProfilesCache = roomMemberProfilesCache,
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled

6
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 androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -32,7 +34,7 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider() val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
val exception = runCatching { provider.provide() }.exceptionOrNull() val exception = runCatching { provider.provide() }.exceptionOrNull()
@ -41,7 +43,7 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider() val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID) provider.Update(currentUserId = A_USER_ID)

4
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, text = text,
rawValue = rawValue, rawValue = rawValue,
type = type, type = type,
backgroundColor = 0,
textColor = 0,
startPadding = 0,
endPadding = 0,
) )
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") = private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =

128
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,
)
}

57
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 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 * This class contains pattern to match the different Matrix ids
* Ref: https://matrix.org/docs/spec/appendices#identifier-grammar * Ref: https://matrix.org/docs/spec/appendices#identifier-grammar
*/ */
object MatrixPatterns { object MatrixPatterns {
// Note: TLD is not mandatory (localhost, IP address...) // 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. // regex pattern to find matrix user ids in a string.
// See https://matrix.org/docs/spec/appendices#historical-user-ids // 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. * @return true if the string is a valid thread id.
*/ */
fun isThreadId(str: String?) = isEventId(str) 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<MatrixPatternResult> {
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)

3
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 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 import io.element.android.libraries.matrix.api.core.UserId
interface PermalinkBuilder { interface PermalinkBuilder {
fun permalinkForUser(userId: UserId): Result<String> fun permalinkForUser(userId: UserId): Result<String>
fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String>
} }
sealed class PermalinkBuilderError : Throwable() { sealed class PermalinkBuilderError : Throwable() {
data object InvalidUserId : PermalinkBuilderError() data object InvalidUserId : PermalinkBuilderError()
data object InvalidRoomAlias : PermalinkBuilderError()
} }

98
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)
}
}
}

11
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 com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink
import org.matrix.rustcomponents.sdk.matrixToUserPermalink import org.matrix.rustcomponents.sdk.matrixToUserPermalink
import javax.inject.Inject import javax.inject.Inject
@ -35,4 +37,13 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
matrixToUserPermalink(userId.value) matrixToUserPermalink(userId.value)
} }
} }
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String> {
if (!MatrixPatterns.isRoomAlias(roomAlias.value)) {
return Result.failure(PermalinkBuilderError.InvalidRoomAlias)
}
return runCatching {
matrixToRoomAliasPermalink(roomAlias.value)
}
}
} }

10
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 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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
class FakePermalinkBuilder( class FakePermalinkBuilder(
private val result: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) } private val permalinkForUserLambda: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) },
private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { Result.failure(Exception("Not implemented")) },
) : PermalinkBuilder { ) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> { override fun permalinkForUser(userId: UserId): Result<String> {
return result(userId) return permalinkForUserLambda(userId)
}
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String> {
return permalinkForRoomAliasLambda(roomAlias)
} }
} }

3
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 package io.element.android.libraries.matrix.ui.messages
import androidx.compose.runtime.staticCompositionLocalOf 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class)
class RoomMemberProfilesCache @Inject constructor() { class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>()) private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())

25
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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId 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.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.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags 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.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape 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.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
@ -90,7 +86,6 @@ import kotlin.time.Duration.Companion.seconds
fun TextComposer( fun TextComposer(
state: TextEditorState, state: TextEditorState,
voiceMessageState: VoiceMessageState, voiceMessageState: VoiceMessageState,
permalinkParser: PermalinkParser,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
onRequestFocus: () -> Unit, onRequestFocus: () -> Unit,
@ -106,6 +101,7 @@ fun TextComposer(
onTyping: (Boolean) -> Unit, onTyping: (Boolean) -> Unit,
onReceiveSuggestion: (Suggestion?) -> Unit, onReceiveSuggestion: (Suggestion?) -> Unit,
onSelectRichContent: ((Uri) -> Unit)?, onSelectRichContent: ((Uri) -> Unit)?,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,
subcomposing: Boolean = false, subcomposing: Boolean = false,
@ -144,8 +140,6 @@ fun TextComposer(
} }
} }
val userProfileCache = LocalRoomMemberProfilesCache.current
val placeholder = if (composerMode.inThread) { val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread) stringResource(id = CommonStrings.action_reply_in_thread)
} else { } else {
@ -155,23 +149,14 @@ fun TextComposer(
is TextEditorState.Rich -> { is TextEditorState.Rich -> {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable { @Composable {
val mentionSpanProvider = LocalMentionSpanProvider.current
TextInput( TextInput(
state = state.richTextEditorState, state = state.richTextEditorState,
subcomposing = subcomposing, subcomposing = subcomposing,
placeholder = placeholder, placeholder = placeholder,
composerMode = composerMode, composerMode = composerMode,
onResetComposerMode = onResetComposerMode, onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> resolveMentionDisplay = resolveMentionDisplay,
val permalinkData = permalinkParser.parse(url) resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
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", "#")) },
onError = onError, onError = onError,
onTyping = onTyping, onTyping = onTyping,
onSelectRichContent = onSelectRichContent, onSelectRichContent = onSelectRichContent,
@ -709,9 +694,6 @@ private fun ATextComposer(
state = state, state = state,
showTextFormatting = showTextFormatting, showTextFormatting = showTextFormatting,
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
},
composerMode = composerMode, composerMode = composerMode,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
onRequestFocus = {}, onRequestFocus = {},
@ -726,6 +708,7 @@ private fun ATextComposer(
onError = {}, onError = {},
onTyping = {}, onTyping = {},
onReceiveSuggestion = {}, onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
onSelectRichContent = null, onSelectRichContent = null,
) )
} }

8
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.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
@ -81,6 +83,8 @@ fun MarkdownTextInput(
} }
} }
val mentionSpanTheme = LocalMentionSpanTheme.current
AndroidView( AndroidView(
modifier = Modifier modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp) .padding(top = 6.dp, bottom = 6.dp)
@ -130,7 +134,9 @@ fun MarkdownTextInput(
editText.applyStyleInCompose(richTextEditorStyle) editText.applyStyleInCompose(richTextEditorStyle)
if (state.text.needsDisplaying()) { if (state.text.needsDisplaying()) {
editText.updateEditableText(state.text.value()) val text = state.text.value()
mentionSpanTheme.updateMentionStyles(text)
editText.updateEditableText(text)
if (canUpdateState) { if (canUpdateState) {
state.text.update(editText.editableText, false) state.text.update(editText.editableText, false)
} }

30
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt

@ -31,16 +31,17 @@ class MentionSpan(
text: String, text: String,
val rawValue: String, val rawValue: String,
val type: Type, val type: Type,
val backgroundColor: Int,
val textColor: Int,
val startPadding: Int,
val endPadding: Int,
val typeface: Typeface = Typeface.DEFAULT,
) : ReplacementSpan() { ) : ReplacementSpan() {
companion object { companion object {
private const val MAX_LENGTH = 20 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 var textWidth = 0
private val backgroundPaint = Paint().apply { private val backgroundPaint = Paint().apply {
isAntiAlias = true isAntiAlias = true
@ -55,6 +56,25 @@ class MentionSpan(
private var mentionText: CharSequence = getActualText(text) 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 { override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
paint.typeface = typeface paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()

154
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 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.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.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf import javax.inject.Inject
@Stable @Stable
class MentionSpanProvider @AssistedInject constructor( open class MentionSpanProvider @Inject constructor(
@Assisted private val currentSessionId: String,
private val permalinkParser: PermalinkParser, 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 { fun getMentionSpanFor(text: String, url: String): MentionSpan {
val permalinkData = permalinkParser.parse(url) val permalinkData = permalinkParser.parse(url)
val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
return when { return when {
permalinkData is PermalinkData.UserLink -> { permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId.value == currentSessionId
MentionSpan( MentionSpan(
text = text, text = text,
rawValue = permalinkData.userId.toString(), rawValue = permalinkData.userId.toString(),
type = MentionSpan.Type.USER, 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 -> { text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
@ -109,23 +40,13 @@ class MentionSpanProvider @AssistedInject constructor(
text = text, text = text,
rawValue = "@room", rawValue = "@room",
type = MentionSpan.Type.EVERYONE, type = MentionSpan.Type.EVERYONE,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
) )
} }
permalinkData is PermalinkData.RoomLink -> { permalinkData is PermalinkData.RoomLink -> {
MentionSpan( MentionSpan(
text = text, text = text,
rawValue = permalinkData.roomIdOrAlias.toString(), rawValue = permalinkData.roomIdOrAlias.identifier,
type = MentionSpan.Type.ROOM, type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
) )
} }
else -> { else -> {
@ -133,77 +54,8 @@ class MentionSpanProvider @AssistedInject constructor(
text = text, text = text,
rawValue = text, rawValue = text,
type = MentionSpan.Type.ROOM, 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)
}
},
)
}

172
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)
}
})
}
}
}

11
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.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Stable @Stable
@ -63,7 +64,7 @@ class MarkdownTextEditorState(
val currentText = SpannableStringBuilder(text.value()) val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room" val replaceText = "@room"
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
currentText.replace(suggestion.start, suggestion.end, ". ") currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1 val end = suggestion.start + 1
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text.update(currentText, true) text.update(currentText, true)
@ -74,7 +75,7 @@ class MarkdownTextEditorState(
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, ". ") currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1 val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true) this.text.update(currentText, true)
@ -86,11 +87,11 @@ class MarkdownTextEditorState(
fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String { fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String {
val charSequence = text.value() val charSequence = text.value()
return if (charSequence is Spanned) { return if (charSequence is Spanned) {
val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) val mentions = charSequence.getMentionSpans()
buildString { buildString {
append(charSequence.toString()) append(charSequence.toString())
if (mentions != null && mentions.isNotEmpty()) { if (mentions.isNotEmpty()) {
for (mention in mentions.reversed()) { for (mention in mentions.sortedByDescending { charSequence.getSpanEnd(it) }) {
val start = charSequence.getSpanStart(mention) val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention) val end = charSequence.getSpanEnd(mention)
when (mention.type) { when (mention.type) {

6
libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt

@ -155,7 +155,7 @@ class MarkdownTextInputTest {
@Test @Test
fun `inserting a mention replaces the existing text with a span`() = runTest { fun `inserting a mention replaces the existing text with a span`() = runTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
val permalinkBuilder = FakePermalinkBuilder(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) val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state) rule.setMarkdownTextInput(state = state)
@ -164,14 +164,14 @@ class MarkdownTextInputTest {
editor = it.findEditor() editor = it.findEditor()
state.insertMention( state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(currentSessionId = A_SESSION_ID.value, permalinkParser = permalinkParser), MentionSpanProvider(permalinkParser = permalinkParser),
permalinkBuilder, permalinkBuilder,
) )
} }
rule.awaitIdle() rule.awaitIdle()
// Text is replaced with a placeholder // Text is replaced with a placeholder
assertThat(editor?.editableText.toString()).isEqualTo(". ") assertThat(editor?.editableText.toString()).isEqualTo("@ ")
// The placeholder contains a MentionSpan // The placeholder contains a MentionSpan
val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty() val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
assertThat(mentionSpans).isNotEmpty() assertThat(mentionSpans).isNotEmpty()

56
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 package io.element.android.libraries.textcomposer.impl.mentions
import android.graphics.Color
import android.net.Uri import android.net.Uri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.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.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import org.junit.Rule import org.junit.Rule
@ -37,66 +36,33 @@ class MentionSpanProviderTest {
@JvmField @Rule @JvmField @Rule
val warmUpRule = WarmUpRule() 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 permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider( private val mentionSpanProvider = MentionSpanProvider(
currentSessionId = currentUserId.value,
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
).apply { )
currentUserBackgroundColor = myUserColor
currentUserTextColor = myUserColor
otherBackgroundColor = otherColor
otherTextColor = otherColor
}
@Test @Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() { fun `getting mention span for a user returns a MentionSpan of type USER`() {
permalinkParser.givenResult(PermalinkData.UserLink(currentUserId)) permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}")
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor) assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.USER)
assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
} }
@Test @Test
fun `getting mention span for other user should return a MentionSpan with normal colors`() { fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
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`() {
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE)
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
} }
@Test @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( permalinkParser.givenResult(
PermalinkData.RoomLink( PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
) )
) )
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.ROOM)
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)
} }
} }

15
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.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.Mention 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.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
@ -60,7 +58,7 @@ class MarkdownTextEditorStateTest {
val member = aRoomMember() val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member) val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) 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) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder) state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
@ -77,7 +75,7 @@ class MarkdownTextEditorStateTest {
val member = aRoomMember() val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member) val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) 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) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder) state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
@ -117,7 +115,7 @@ class MarkdownTextEditorStateTest {
@Test @Test
fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() {
val text = "No mentions here" 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) val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
@ -148,15 +146,14 @@ class MarkdownTextEditorStateTest {
} }
private fun aMentionSpanProvider( private fun aMentionSpanProvider(
currentSessionId: SessionId = A_SESSION_ID,
permalinkParser: FakePermalinkParser = FakePermalinkParser(), permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider { ): MentionSpanProvider {
return MentionSpanProvider(currentSessionId.value, permalinkParser) return MentionSpanProvider(permalinkParser)
} }
private fun aMarkdownTextWithMentions(): CharSequence { private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0) val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER)
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE, 0, 0, 0, 0) val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE)
return buildSpannedString { return buildSpannedString {
append("Hello ") append("Hello ")
inSpans(userMentionSpan) { inSpans(userMentionSpan) {

0
tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Day_0_en.png → tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en.png

0
tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpan_Night_0_en.png → tests/uitests/src/test/snapshots/images/libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en.png

2
tools/detekt/detekt.yml

@ -226,7 +226,7 @@ Compose:
- LocalCameraPositionState - LocalCameraPositionState
- LocalTimelineItemPresenterFactories - LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache - LocalRoomMemberProfilesCache
- LocalMentionSpanProvider - LocalMentionSpanTheme
CompositionLocalNaming: CompositionLocalNaming:
active: true active: true
ContentEmitterReturningValues: ContentEmitterReturningValues:

Loading…
Cancel
Save