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

1
changelog.d/3051.misc Normal file
View file

@ -0,0 +1 @@
Resolve display names in mentions in real time, also send mentions with user ids as the fallback text for the link representation of the mentions.

View file

@ -18,7 +18,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
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.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource 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.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.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.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -82,6 +92,9 @@ class MessagesFlowNode @AssistedInject constructor(
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
mentionSpanProviderFactory: MentionSpanProvider.Factory,
) : BaseFlowNode<MessagesFlowNode.NavTarget>( ) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -137,6 +150,18 @@ class MessagesFlowNode @AssistedInject constructor(
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.Messages -> { is NavTarget.Messages -> {
@ -345,6 +370,13 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { 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.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion 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.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -104,7 +103,6 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: DefaultMessageComposerContext, private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@ -215,7 +213,7 @@ class MessageComposerPresenter @Inject constructor(
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() } val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
LaunchedEffect(isMentionsEnabled) { LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean { suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
@ -279,14 +277,7 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
val mentionSpanProvider = if (isTesting) { val mentionSpanProvider = LocalMentionSpanProvider.current
null
} else {
rememberMentionSpanProvider(
currentUserId = room.sessionId,
permalinkParser = permalinkParser,
)
}
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
@ -415,19 +406,17 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState.insertAtRoomMentionAtSuggestion() richTextEditorState.insertAtRoomMentionAtSuggestion()
} }
is ResolvedMentionSuggestion.Member -> { 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 val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
} }
} else if (markdownTextEditorState.currentMentionSuggestion != null) { } else if (markdownTextEditorState.currentMentionSuggestion != null) {
mentionSpanProvider?.let { markdownTextEditorState.insertMention(
markdownTextEditorState.insertMention( mention = event.mention,
mention = event.mention, mentionSpanProvider = mentionSpanProvider,
mentionSpanProvider = it, permalinkBuilder = permalinkBuilder,
permalinkBuilder = permalinkBuilder, )
)
}
suggestionSearchTrigger.value = null suggestionSearchTrigger.value = null
} }
} }
@ -446,7 +435,6 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) } 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.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -38,7 +37,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>, val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) )

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.aRichTextEditorState
@ -57,6 +56,5 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {}, eventSink = {},
) )

View file

@ -124,7 +124,6 @@ internal fun MessageComposerView(
onReceiveSuggestion = ::onSuggestionReceived, onReceiveSuggestion = ::onSuggestionReceived,
onError = ::onError, onError = ::onError,
onTyping = ::onTyping, onTyping = ::onTyping,
currentUserId = state.currentUserId,
onSelectRichContent = ::sendUri, 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.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle 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.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
@ -40,9 +39,7 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor( class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
private val permalinkParser: PermalinkParser,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
@ -53,10 +50,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider( val mentionSpanProvider = LocalMentionSpanProvider.current
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val context = LocalContext.current val context = LocalContext.current

View file

@ -17,11 +17,15 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString import android.text.SpannableString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.Modifier
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics 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.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.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.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
@ -47,10 +56,8 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary, LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) { ) {
val formattedBody = content.formattedBody val body = getTextWithResolvedMentions(content)
val body = SpannableString(formattedBody ?: content.body) Box(modifier.semantics { contentDescription = content.plainText }) {
Box(modifier.semantics { contentDescription = body.toString() }) {
EditorStyledText( EditorStyledText(
text = body, text = body,
onLinkClickedListener = onLinkClick, 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 @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemTextViewPreview( 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType 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.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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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.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.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -778,7 +776,6 @@ class MessagesPresenterTest {
messageComposerContext = DefaultMessageComposerContext(), messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory, permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(), permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom), timelineController = TimelineController(matrixRoom),

View file

@ -470,7 +470,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
) { ) {
setContent { setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(
LocalInspectionMode provides true
) {
MessagesView( MessagesView(
state = state, state = state,
onBackClick = onBackClick, 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY 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_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 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_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 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.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.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -1057,7 +1054,6 @@ class MessageComposerPresenterTest {
analyticsService, analyticsService,
DefaultMessageComposerContext(), DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,

View file

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

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

View file

@ -44,7 +44,8 @@ serialization_json = "1.6.3"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.2" sqldelight = "2.0.2"
wysiwyg = "2.37.3" # TODO use a stable version before merging
wysiwyg = "2.37.3-SNAPSHOT"
telephoto = "0.11.2" telephoto = "0.11.2"
# DI # DI

View file

@ -0,0 +1,44 @@
/*
* 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.libraries.matrix.ui.messages
import androidx.compose.runtime.staticCompositionLocalOf
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
class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
private val _lastCacheUpdate = MutableStateFlow(0L)
val lastCacheUpdate: StateFlow<Long> = _lastCacheUpdate
fun replace(items: List<RoomMember>) {
cache.value = items.associateBy { it.userId }
_lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1)
}
fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName
}
}
val LocalRoomMemberProfilesCache = staticCompositionLocalOf {
RoomMemberProfilesCache()
}

View file

@ -17,6 +17,7 @@
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
@ -27,7 +28,13 @@ android {
} }
} }
anvil {
generateDaggerFactories.set(true)
}
dependencies { dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)

View file

@ -51,13 +51,13 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource 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.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
@ -71,7 +71,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
@ -94,7 +94,6 @@ fun TextComposer(
permalinkParser: PermalinkParser, permalinkParser: PermalinkParser,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
currentUserId: UserId,
onRequestFocus: () -> Unit, onRequestFocus: () -> Unit,
onSendMessage: () -> Unit, onSendMessage: () -> Unit,
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
@ -146,6 +145,8 @@ fun TextComposer(
} }
} }
val userProfileCache = LocalRoomMemberProfilesCache.current
val placeholder = if (composerMode.inThread) { val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread) stringResource(id = CommonStrings.action_reply_in_thread)
} else { } else {
@ -155,17 +156,22 @@ fun TextComposer(
is TextEditorState.Rich -> { is TextEditorState.Rich -> {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable { @Composable {
val mentionSpanProvider = rememberMentionSpanProvider( val mentionSpanProvider = LocalMentionSpanProvider.current
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
TextInput( TextInput(
state = state.richTextEditorState, state = state.richTextEditorState,
subcomposing = subcomposing, subcomposing = subcomposing,
placeholder = placeholder, placeholder = placeholder,
composerMode = composerMode, composerMode = composerMode,
onResetComposerMode = onResetComposerMode, onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, 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", "#")) }, resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
onError = onError, onError = onError,
onTyping = onTyping, onTyping = onTyping,
@ -519,7 +525,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost"),
) )
}, },
{ {
@ -528,7 +533,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -542,7 +546,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -551,7 +554,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
} }
) )
@ -568,7 +570,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
ATextComposer( ATextComposer(
@ -577,7 +578,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
ATextComposer( ATextComposer(
@ -590,7 +590,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
})) }))
} }
@ -604,7 +603,6 @@ internal fun TextComposerEditPreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
})) }))
} }
@ -618,7 +616,6 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
})) }))
} }
@ -642,7 +639,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"To preview larger textfields and long lines with overflow" "To preview larger textfields and long lines with overflow"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -659,7 +655,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"To preview larger textfields and long lines with overflow" "To preview larger textfields and long lines with overflow"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -679,7 +674,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "image.jpg" defaultContent = "image.jpg"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -699,7 +693,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "video.mp4" defaultContent = "video.mp4"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -719,7 +712,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "logs.txt" defaultContent = "logs.txt"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -739,7 +731,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "Shared location" defaultContent = "Shared location"
), ),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
} }
) )
@ -757,7 +748,6 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
PreviewColumn(items = persistentListOf({ PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform())) VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))
@ -818,7 +808,6 @@ private fun ATextComposer(
voiceMessageState: VoiceMessageState, voiceMessageState: VoiceMessageState,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
currentUserId: UserId,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,
) { ) {
TextComposer( TextComposer(
@ -830,7 +819,6 @@ private fun ATextComposer(
}, },
composerMode = composerMode, composerMode = composerMode,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
currentUserId = currentUserId,
onRequestFocus = {}, onRequestFocus = {},
onSendMessage = {}, onSendMessage = {},
onResetComposerMode = {}, onResetComposerMode = {},

View file

@ -21,12 +21,14 @@ import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
import android.text.style.ReplacementSpan import android.text.style.ReplacementSpan
import androidx.core.text.getSpans
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MentionSpan( class MentionSpan(
val text: String, text: String,
val rawValue: String, val rawValue: String,
val type: Type, val type: Type,
val backgroundColor: Int, val backgroundColor: Int,
@ -39,23 +41,27 @@ class MentionSpan(
private const val MAX_LENGTH = 20 private const val MAX_LENGTH = 20
} }
private var actualText: CharSequence? = null
private var textWidth = 0 private var textWidth = 0
private val backgroundPaint = Paint().apply { private val backgroundPaint = Paint().apply {
isAntiAlias = true isAntiAlias = true
color = backgroundColor color = backgroundColor
} }
var text: String = text
set(value) {
field = value
mentionText = getActualText(text)
}
private var mentionText: CharSequence = getActualText(text)
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
val mentionText = getActualText(this.text)
paint.typeface = typeface paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
return textWidth + startPadding + endPadding return textWidth + startPadding + endPadding
} }
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val mentionText = getActualText(this.text)
// Extra vertical space to add below the baseline (y). This helps us center the span vertically // Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
@ -68,7 +74,6 @@ class MentionSpan(
} }
private fun getActualText(text: String): CharSequence { private fun getActualText(text: String): CharSequence {
if (actualText != null) return actualText!!
return buildString { return buildString {
val mentionText = text.orEmpty() val mentionText = text.orEmpty()
when (type) { when (type) {
@ -87,7 +92,6 @@ class MentionSpan(
if (mentionText.length > MAX_LENGTH) { if (mentionText.length > MAX_LENGTH) {
append("") append("")
} }
actualText = this
} }
} }
@ -96,3 +100,18 @@ class MentionSpan(
ROOM, ROOM,
} }
} }
fun CharSequence.getMentionSpans(): List<MentionSpan> {
return if (this is android.text.Spanned) {
val customMentionSpans = getSpans<CustomMentionSpan>()
if (customMentionSpans.isNotEmpty()) {
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
customMentionSpans.map { it.providedSpan }.filterIsInstance<MentionSpan>()
} else {
// Otherwise try to get the spans directly
getSpans<MentionSpan>().toList()
}
} else {
emptyList()
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer.mentions
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -25,12 +26,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.buildSpannedString 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.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -40,7 +45,6 @@ import io.element.android.libraries.designsystem.theme.currentUserMentionPillTex
import io.element.android.libraries.designsystem.theme.mentionPillBackground import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText 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.RoomAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -48,22 +52,28 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Stable @Stable
class MentionSpanProvider( class MentionSpanProvider @AssistedInject constructor(
private val currentSessionId: SessionId, @Assisted private val currentSessionId: String,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private var currentUserTextColor: Int = 0,
private var currentUserBackgroundColor: Int = Color.WHITE,
private var otherTextColor: Int = 0,
private var otherBackgroundColor: Int = Color.WHITE,
) { ) {
@AssistedFactory
interface Factory {
fun create(currentSessionId: String): MentionSpanProvider
}
private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp) private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp)
private val paddingValuesPx = mutableStateOf(0 to 0) private val paddingValuesPx = mutableStateOf(0 to 0)
private val typeface = mutableStateOf(Typeface.DEFAULT) 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") @Suppress("ComposableNaming")
@Composable @Composable
internal fun setup() { fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb() otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
@ -82,7 +92,7 @@ class MentionSpanProvider(
val (startPaddingPx, endPaddingPx) = paddingValuesPx.value val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
return when { return when {
permalinkData is PermalinkData.UserLink -> { permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId == currentSessionId val isCurrentUser = permalinkData.userId.value == currentSessionId
MentionSpan( MentionSpan(
text = text, text = text,
rawValue = permalinkData.userId.toString(), rawValue = permalinkData.userId.toString(),
@ -134,43 +144,30 @@ class MentionSpanProvider(
} }
} }
@Composable
fun rememberMentionSpanProvider(
currentUserId: SessionId,
permalinkParser: PermalinkParser,
): MentionSpanProvider {
val provider = remember(currentUserId) {
MentionSpanProvider(
currentSessionId = currentUserId,
permalinkParser = permalinkParser,
)
}
provider.setup()
return provider
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun MentionSpanPreview() { internal fun MentionSpanPreview() {
val provider = rememberMentionSpanProvider(
currentUserId = SessionId("@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")
}
}
},
)
ElementPreview { ElementPreview {
provider.setup() 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() val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
@ -199,3 +196,14 @@ internal fun MentionSpanPreview() {
}) })
} }
} }
val LocalMentionSpanProvider = staticCompositionLocalOf {
MentionSpanProvider(
currentSessionId = "@dummy:value.org",
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(Uri.EMPTY)
}
},
)
}

View file

@ -98,7 +98,7 @@ class MarkdownTextEditorState(
replace(start, end, "@room") replace(start, end, "@room")
} else { } else {
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
replace(start, end, "[${mention.text}]($link)") replace(start, end, "[${mention.rawValue}]($link)")
} }
} }
} }

View file

@ -164,7 +164,7 @@ class MarkdownTextInputTest {
editor = it.findEditor() editor = it.findEditor()
state.insertMention( state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser), MentionSpanProvider(currentSessionId = A_SESSION_ID.value, permalinkParser = permalinkParser),
permalinkBuilder, permalinkBuilder,
) )
} }

View file

@ -43,13 +43,14 @@ class MentionSpanProviderTest {
private val permalinkParser = FakePermalinkParser() private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider( private val mentionSpanProvider = MentionSpanProvider(
currentSessionId = currentUserId, currentSessionId = currentUserId.value,
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
currentUserBackgroundColor = myUserColor, ).apply {
currentUserTextColor = myUserColor, currentUserBackgroundColor = myUserColor
otherBackgroundColor = otherColor, currentUserTextColor = myUserColor
otherTextColor = otherColor, otherBackgroundColor = otherColor
) otherTextColor = otherColor
}
@Test @Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() { fun `getting mention span for current user should return a MentionSpan with custom colors`() {

View file

@ -124,7 +124,7 @@ class MarkdownTextEditorStateTest {
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo( assertThat(markdown).isEqualTo(
"Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room" "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
) )
} }
@ -151,7 +151,7 @@ class MarkdownTextEditorStateTest {
currentSessionId: SessionId = A_SESSION_ID, currentSessionId: SessionId = A_SESSION_ID,
permalinkParser: FakePermalinkParser = FakePermalinkParser(), permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider { ): MentionSpanProvider {
return MentionSpanProvider(currentSessionId, permalinkParser) return MentionSpanProvider(currentSessionId.value, permalinkParser)
} }
private fun aMarkdownTextWithMentions(): CharSequence { private fun aMarkdownTextWithMentions(): CharSequence {

View file

@ -222,7 +222,13 @@ Compose:
CompositionLocalAllowlist: CompositionLocalAllowlist:
active: true active: true
# You can optionally define a list of CompositionLocals that are allowed here # You can optionally define a list of CompositionLocals that are allowed here
allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher, LocalCameraPositionState, LocalTimelineItemPresenterFactories allowedCompositionLocals:
- LocalCompoundColors
- LocalSnackbarDispatcher
- LocalCameraPositionState
- LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache
- LocalMentionSpanProvider
CompositionLocalNaming: CompositionLocalNaming:
active: true active: true
ContentEmitterReturningValues: ContentEmitterReturningValues: