Resolve display names in mentions in real time (#3051)

* Resolve display names in mentions in real time

* Use `LocalRoomMemberProfilesCache` to avoid having to implement `TextMessagePresenter`

* Also use local composition provider for `MentionSpanProvider`
This commit is contained in:
Jorge Martin Espinosa 2024-06-21 11:57:36 +02:00 committed by GitHub
parent 213a5c2942
commit ce91fe4ab2
24 changed files with 431 additions and 142 deletions

View file

@ -18,7 +18,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
@ -64,12 +66,20 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.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.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@ -82,6 +92,9 @@ class MessagesFlowNode @AssistedInject constructor(
private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
mentionSpanProviderFactory: MentionSpanProvider.Factory,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -137,6 +150,18 @@ class MessagesFlowNode @AssistedInject constructor(
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
private val mentionSpanProvider = mentionSpanProviderFactory.create(room.sessionId.value)
override fun onBuilt() {
super.onBuilt()
room.membersStateFlow
.onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Messages -> {
@ -345,6 +370,13 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier)
mentionSpanProvider.updateStyles()
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
LocalMentionSpanProvider provides mentionSpanProvider,
) {
BackstackWithOverlayBox(modifier)
}
}
}

View file

@ -54,15 +54,14 @@ 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.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
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.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@ -104,7 +103,6 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
@ -215,7 +213,7 @@ class MessageComposerPresenter @Inject constructor(
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
@ -279,14 +277,7 @@ class MessageComposerPresenter @Inject constructor(
}
}
val mentionSpanProvider = if (isTesting) {
null
} else {
rememberMentionSpanProvider(
currentUserId = room.sessionId,
permalinkParser = permalinkParser,
)
}
val mentionSpanProvider = LocalMentionSpanProvider.current
fun handleEvents(event: MessageComposerEvents) {
when (event) {
@ -415,19 +406,17 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState.insertAtRoomMentionAtSuggestion()
}
is ResolvedMentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val text = mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
}
} else if (markdownTextEditorState.currentMentionSuggestion != null) {
mentionSpanProvider?.let {
markdownTextEditorState.insertMention(
mention = event.mention,
mentionSpanProvider = it,
permalinkBuilder = permalinkBuilder,
)
}
markdownTextEditorState.insertMention(
mention = event.mention,
mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder,
)
suggestionSearchTrigger.value = null
}
}
@ -446,7 +435,6 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) }
)
}

View file

@ -19,7 +19,6 @@ 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.core.UserId
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
@ -38,7 +37,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit,
)

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.textcomposer.aRichTextEditorState
@ -57,6 +56,5 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {},
)

View file

@ -124,7 +124,6 @@ internal fun MessageComposerView(
onReceiveSuggestion = ::onSuggestionReceived,
onError = ::onError,
onTyping = ::onTyping,
currentUserId = state.currentUserId,
onSelectRichContent = ::sendUri,
)
}

View file

@ -28,9 +28,8 @@ import io.element.android.libraries.core.bool.orFalse
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.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay
@ -40,9 +39,7 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor(
private val permalinkParser: PermalinkParser,
) : HtmlConverterProvider {
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable
@ -53,10 +50,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val mentionSpanProvider = LocalMentionSpanProvider.current
val context = LocalContext.current

View file

@ -17,11 +17,15 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@ -33,7 +37,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
@ -47,10 +56,8 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body)
Box(modifier.semantics { contentDescription = body.toString() }) {
val body = getTextWithResolvedMentions(content)
Box(modifier.semantics { contentDescription = content.plainText }) {
EditorStyledText(
text = body,
onLinkClickedListener = onLinkClick,
@ -62,6 +69,39 @@ fun TimelineItemTextView(
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
val formattedBody = remember(content.htmlBody, lastCacheUpdate) {
updateMentionSpans(content.formattedBody, userProfileCache)
SpannableString(content.formattedBody ?: content.body)
}
return formattedBody
}
private fun updateMentionSpans(text: CharSequence?, cache: RoomMemberProfilesCache): Boolean {
var changedContents = false
if (text != null) {
for (mentionSpan in text.getMentionSpans()) {
when (mentionSpan.type) {
MentionSpan.Type.USER -> {
val displayName = cache.getDisplayName(UserId(mentionSpan.rawValue)) ?: mentionSpan.rawValue
if (mentionSpan.text != displayName) {
changedContents = true
mentionSpan.text = displayName
}
}
// Nothing yet for room mentions
else -> Unit
}
}
}
return changedContents
}
@PreviewsDayNight
@Composable
internal fun TimelineItemTextViewPreview(

View file

@ -69,13 +69,11 @@ 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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -778,7 +776,6 @@ class MessagesPresenterTest {
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom),

View file

@ -470,7 +470,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
) {
setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider(LocalInspectionMode provides true) {
CompositionLocalProvider(
LocalInspectionMode provides true
) {
MessagesView(
state = state,
onBackClick = onBackClick,

View file

@ -47,19 +47,16 @@ 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
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
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.FakeMatrixRoom
@ -1057,7 +1054,6 @@ class MessageComposerPresenterTest {
analyticsService,
DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,

View file

@ -21,7 +21,6 @@ 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 org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -33,9 +32,7 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider(
permalinkParser = FakePermalinkParser(),
)
val provider = DefaultHtmlConverterProvider()
val exception = runCatching { provider.provide() }.exceptionOrNull()
@ -44,9 +41,7 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider(
permalinkParser = FakePermalinkParser(),
)
val provider = DefaultHtmlConverterProvider()
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID)

View file

@ -0,0 +1,175 @@
/*
* 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
*
* http://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.timeline.components.event
import android.text.SpannableString
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TimelineTextViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
}
@Test
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest {
val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>")
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
}
@Test
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
assertThat(result.getMentionSpans()).isEmpty()
assertThat(result.toString()).isEqualTo(charSequence)
}
@Test
fun `getTextWithResolvedMentions - with Room mention does nothing`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) {
append(A_ROOM_ID.value)
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
assertThat(result).isEqualTo(charSequence)
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID.value)) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text with user id if no display name is cached`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID_2.value)) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo(A_USER_ID_2.value)
}
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
content: TimelineItemTextBasedContent,
): CharSequence {
val completable = CompletableDeferred<CharSequence>()
setContent {
val roomMemberProfilesCache = RoomMemberProfilesCache().apply {
replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME)))
}
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
) {
completable.complete(getTextWithResolvedMentions(content = content))
}
}
return completable.await()
}
private fun aMentionSpan(
rawValue: String,
text: String = "",
type: MentionSpan.Type = MentionSpan.Type.USER
) = MentionSpan(
text = text,
rawValue = rawValue,
type = type,
backgroundColor = 0,
textColor = 0,
startPadding = 0,
endPadding = 0,
)
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
TimelineItemTextContent(
body = body,
htmlDocument = null,
formattedBody = formattedBody,
isEdited = false
)
}