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(