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

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

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

7
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt

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

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

@ -107,7 +107,6 @@ internal fun MessageComposerView( @@ -107,7 +107,6 @@ internal fun MessageComposerView(
modifier = modifier,
state = state.textEditorState,
voiceMessageState = voiceMessageState.voiceMessageState,
permalinkParser = state.permalinkParser,
subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
@ -122,6 +121,7 @@ internal fun MessageComposerView( @@ -122,6 +121,7 @@ internal fun MessageComposerView(
onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived,
resolveMentionDisplay = state.resolveMentionDisplay,
onError = ::onError,
onTyping = ::onTyping,
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 @@ -29,7 +29,8 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay
@ -39,7 +40,9 @@ import javax.inject.Inject @@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
class DefaultHtmlConverterProvider @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable
@ -50,20 +53,23 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider @@ -50,20 +53,23 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = LocalMentionSpanProvider.current
val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanProvider) {
htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#"))
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")
mentionSpan.update(mentionSpanTheme)
return TextDisplay.Custom(mentionSpan)
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
mentionSpan.update(mentionSpanTheme)
return TextDisplay.Custom(mentionSpan)
}
},
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }

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

80
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt

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

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

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

4
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt

@ -159,10 +159,6 @@ class TimelineTextViewTest { @@ -159,10 +159,6 @@ class TimelineTextViewTest {
text = text,
rawValue = rawValue,
type = type,
backgroundColor = 0,
textColor = 0,
startPadding = 0,
endPadding = 0,
)
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =

128
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelperTest.kt

@ -0,0 +1,128 @@ @@ -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 @@ @@ -16,13 +16,16 @@
package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
/**
* This class contains pattern to match the different Matrix ids
* Ref: https://matrix.org/docs/spec/appendices#identifier-grammar
*/
object MatrixPatterns {
// Note: TLD is not mandatory (localhost, IP address...)
private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?"
private const val DOMAIN_REGEX = ":[A-Za-z0-9.-]+(:[0-9]{2,5})?"
// regex pattern to find matrix user ids in a string.
// See https://matrix.org/docs/spec/appendices#historical-user-ids
@ -109,4 +112,56 @@ object MatrixPatterns { @@ -109,4 +112,56 @@ object MatrixPatterns {
* @return true if the string is a valid thread id.
*/
fun isThreadId(str: String?) = isEventId(str)
/**
* Finds existing ids or aliases in a [CharSequence].
* Note not all cases are implemented.
*/
fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List<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 @@ @@ -16,12 +16,15 @@
package io.element.android.libraries.matrix.api.permalink
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
interface PermalinkBuilder {
fun permalinkForUser(userId: UserId): Result<String>
fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String>
}
sealed class PermalinkBuilderError : Throwable() {
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 @@ @@ -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 @@ -19,9 +19,11 @@ package io.element.android.libraries.matrix.impl.permalink
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink
import org.matrix.rustcomponents.sdk.matrixToUserPermalink
import javax.inject.Inject
@ -35,4 +37,13 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder { @@ -35,4 +37,13 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
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 @@ @@ -16,13 +16,19 @@
package io.element.android.libraries.matrix.test.permalink
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
class FakePermalinkBuilder(
private val result: (UserId) -> Result<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 {
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 @@ @@ -17,12 +17,15 @@
package io.element.android.libraries.matrix.ui.messages
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@SingleIn(RoomScope::class)
class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<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 @@ -52,9 +52,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags
@ -70,7 +67,6 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin @@ -70,7 +67,6 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -90,7 +86,6 @@ import kotlin.time.Duration.Companion.seconds @@ -90,7 +86,6 @@ import kotlin.time.Duration.Companion.seconds
fun TextComposer(
state: TextEditorState,
voiceMessageState: VoiceMessageState,
permalinkParser: PermalinkParser,
composerMode: MessageComposerMode,
enableVoiceMessages: Boolean,
onRequestFocus: () -> Unit,
@ -106,6 +101,7 @@ fun TextComposer( @@ -106,6 +101,7 @@ fun TextComposer(
onTyping: (Boolean) -> Unit,
onReceiveSuggestion: (Suggestion?) -> Unit,
onSelectRichContent: ((Uri) -> Unit)?,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
modifier: Modifier = Modifier,
showTextFormatting: Boolean = false,
subcomposing: Boolean = false,
@ -144,8 +140,6 @@ fun TextComposer( @@ -144,8 +140,6 @@ fun TextComposer(
}
}
val userProfileCache = LocalRoomMemberProfilesCache.current
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else {
@ -155,23 +149,14 @@ fun TextComposer( @@ -155,23 +149,14 @@ fun TextComposer(
is TextEditorState.Rich -> {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable {
val mentionSpanProvider = LocalMentionSpanProvider.current
TextInput(
state = state.richTextEditorState,
subcomposing = subcomposing,
placeholder = placeholder,
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url ->
val permalinkData = permalinkParser.parse(url)
if (permalinkData is PermalinkData.UserLink) {
val displayNameOrId = userProfileCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(displayNameOrId, url))
} else {
TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
}
},
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
onError = onError,
onTyping = onTyping,
onSelectRichContent = onSelectRichContent,
@ -709,9 +694,6 @@ private fun ATextComposer( @@ -709,9 +694,6 @@ private fun ATextComposer(
state = state,
showTextFormatting = showTextFormatting,
voiceMessageState = voiceMessageState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
},
composerMode = composerMode,
enableVoiceMessages = enableVoiceMessages,
onRequestFocus = {},
@ -726,6 +708,7 @@ private fun ATextComposer( @@ -726,6 +708,7 @@ private fun ATextComposer(
onError = {},
onTyping = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
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 @@ -39,7 +39,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -81,6 +83,8 @@ fun MarkdownTextInput( @@ -81,6 +83,8 @@ fun MarkdownTextInput(
}
}
val mentionSpanTheme = LocalMentionSpanTheme.current
AndroidView(
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
@ -130,7 +134,9 @@ fun MarkdownTextInput( @@ -130,7 +134,9 @@ fun MarkdownTextInput(
editText.applyStyleInCompose(richTextEditorStyle)
if (state.text.needsDisplaying()) {
editText.updateEditableText(state.text.value())
val text = state.text.value()
mentionSpanTheme.updateMentionStyles(text)
editText.updateEditableText(text)
if (canUpdateState) {
state.text.update(editText.editableText, false)
}

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

@ -31,16 +31,17 @@ class MentionSpan( @@ -31,16 +31,17 @@ class MentionSpan(
text: String,
val rawValue: String,
val type: Type,
val backgroundColor: Int,
val textColor: Int,
val startPadding: Int,
val endPadding: Int,
val typeface: Typeface = Typeface.DEFAULT,
) : ReplacementSpan() {
companion object {
private const val MAX_LENGTH = 20
}
var backgroundColor: Int = 0
var textColor: Int = 0
var startPadding: Int = 0
var endPadding: Int = 0
var typeface: Typeface = Typeface.DEFAULT
private var textWidth = 0
private val backgroundPaint = Paint().apply {
isAntiAlias = true
@ -55,6 +56,25 @@ class MentionSpan( @@ -55,6 +56,25 @@ class MentionSpan(
private var mentionText: CharSequence = getActualText(text)
fun update(mentionSpanTheme: MentionSpanTheme) {
val isCurrentUser = rawValue == mentionSpanTheme.currentUserId?.value
backgroundColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
Type.ROOM -> mentionSpanTheme.otherBackgroundColor
Type.EVERYONE -> mentionSpanTheme.otherBackgroundColor
}
textColor = when (type) {
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
Type.ROOM -> mentionSpanTheme.otherTextColor
Type.EVERYONE -> mentionSpanTheme.otherTextColor
}
backgroundPaint.color = backgroundColor
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
startPadding = startPaddingPx
endPadding = endPaddingPx
typeface = mentionSpanTheme.typeface.value
}
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()

154
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt

@ -16,92 +16,23 @@ @@ -16,92 +16,23 @@
package io.element.android.libraries.textcomposer.mentions
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.buildSpannedString
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.rememberTypeface
import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
@Stable
class MentionSpanProvider @AssistedInject constructor(
@Assisted private val currentSessionId: String,
open class MentionSpanProvider @Inject constructor(
private val permalinkParser: PermalinkParser,
) {
@AssistedFactory
interface Factory {
fun create(currentSessionId: String): MentionSpanProvider
}
private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp)
private val paddingValuesPx = mutableStateOf(0 to 0)
private val typeface = mutableStateOf(Typeface.DEFAULT)
internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0
internal var otherBackgroundColor: Int = Color.WHITE
@Suppress("ComposableNaming")
@Composable
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb()
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
with(LocalDensity.current) {
val leftPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current).roundToPx()
val rightPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current).roundToPx()
paddingValuesPx.value = leftPadding to rightPadding
}
}
fun getMentionSpanFor(text: String, url: String): MentionSpan {
val permalinkData = permalinkParser.parse(url)
val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
return when {
permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId.value == currentSessionId
MentionSpan(
text = text,
rawValue = permalinkData.userId.toString(),
type = MentionSpan.Type.USER,
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
)
}
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
@ -109,23 +40,13 @@ class MentionSpanProvider @AssistedInject constructor( @@ -109,23 +40,13 @@ class MentionSpanProvider @AssistedInject constructor(
text = text,
rawValue = "@room",
type = MentionSpan.Type.EVERYONE,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
)
}
permalinkData is PermalinkData.RoomLink -> {
MentionSpan(
text = text,
rawValue = permalinkData.roomIdOrAlias.toString(),
rawValue = permalinkData.roomIdOrAlias.identifier,
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
)
}
else -> {
@ -133,77 +54,8 @@ class MentionSpanProvider @AssistedInject constructor( @@ -133,77 +54,8 @@ class MentionSpanProvider @AssistedInject constructor(
text = text,
rawValue = text,
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MentionSpanPreview() {
ElementPreview {
val provider = remember {
MentionSpanProvider(
currentSessionId = "@me:matrix.org",
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
"https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
eventId = null,
viaParameters = persistentListOf(),
)
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
}
provider.updateStyles()
val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
AndroidView(factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("This is a ")
append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ")
append("@mention", mentionSpanOther(), 0)
append(" to other user. This one is for a room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
}
setTextColor(textColor)
}
})
}
}
val LocalMentionSpanProvider = staticCompositionLocalOf {
MentionSpanProvider(
currentSessionId = "@dummy:value.org",
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(Uri.EMPTY)
}
},
)
}

172
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt

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

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

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

56
libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt

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

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

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: @@ -226,7 +226,7 @@ Compose:
- LocalCameraPositionState
- LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache
- LocalMentionSpanProvider
- LocalMentionSpanTheme
CompositionLocalNaming:
active: true
ContentEmitterReturningValues:

Loading…
Cancel
Save