Change (mention span) : rework and add more cases (#4476)

* change(mention span) : improve truncation logic

* change(mention span) : fix theme switching

* change(mention span) : start to pillify permalinks

* change(mention span) : use permalink directly

* change(mention span) : start improving mention type

* change(mention span) : use the appropriate MentionSpanProvider methods

* change(mention span) : introduce MentionSpanFormatter

* change(mention span) : introduce MentionSpanUpdater

* change(mention span) : Improve RoomNameCaches

* change(mention span) : remove useless param on HtmlConverterProvider

* change(mention span) : fix some remaining issues on the composer

* change(mention span) : remove pillifiedBody

* change(mention span) : fix some issues with pillification

* change(mention span) : fix getMentionsSpans

* change(mention span) : make sure all tests passes

* change(mention span) : remove the coroutine from the caches and a MentionSpanFormatterTest

* change(mention span) : add more tests on pillification

* change(mention span) : clean up

* Update screenshots

* change(mention span) : remove unexpected print

* change(mention span) : remove default values in constructor of TimelineTextBasedContent classes

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
ganfra 2025-03-28 11:20:32 +01:00 committed by GitHub
parent a962479788
commit 042c0c5a6b
48 changed files with 1156 additions and 568 deletions

View file

@ -57,6 +57,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
@ -73,17 +74,19 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.Timeline
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.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
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.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@ -100,11 +103,14 @@ class MessagesFlowNode @AssistedInject constructor(
private val locationService: LocationService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
private val mentionSpanUpdater: MentionSpanUpdater,
private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@ -172,13 +178,29 @@ class MessagesFlowNode @AssistedInject constructor(
timelineController.close()
}
)
setupCacheUpdaters()
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
}
private fun setupCacheUpdaters() {
room.membersStateFlow
.onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
withContext(coroutineDispatchers.computation) {
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
}
}
.launchIn(lifecycleScope)
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
matrixClient.roomListService
.allRooms
.summaries
.onEach {
withContext(coroutineDispatchers.computation) {
roomNamesCache.replace(it)
}
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -465,10 +487,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
mentionSpanTheme.updateStyles()
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
LocalMentionSpanTheme provides mentionSpanTheme,
LocalMentionSpanUpdater provides mentionSpanUpdater
) {
BackstackWithOverlayBox(modifier)
}

View file

@ -128,7 +128,7 @@ class MessagesPresenter @AssistedInject constructor(
@Composable
override fun present(): MessagesState {
htmlConverterProvider.Update(currentUserId = room.sessionId)
htmlConverterProvider.Update()
val roomInfo by room.roomInfoFlow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()

View file

@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions(
onDeleteVoiceMessage = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onError = {},
onTyping = {},
onSelectRichContent = {},

View file

@ -47,7 +47,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -56,7 +55,6 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
@ -65,7 +63,6 @@ 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.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@ -119,7 +116,6 @@ class MessageComposerPresenter @AssistedInject constructor(
private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter<MessageComposerState> {
@AssistedFactory
@ -331,7 +327,6 @@ class MessageComposerPresenter @AssistedInject constructor(
markdownTextEditorState.insertSuggestion(
resolvedSuggestion = event.resolvedSuggestion,
mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder,
)
suggestionSearchTrigger.value = null
}
@ -344,23 +339,24 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
val mentionSpanTheme = LocalMentionSpanTheme.current
val resolveMentionDisplay = remember(mentionSpanTheme) {
val resolveMentionDisplay = remember {
{ text: String, url: String ->
val permalinkData = permalinkParser.parse(url)
if (permalinkData is PermalinkData.UserLink) {
val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)
mentionSpan.update(mentionSpanTheme)
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
if (mentionSpan != null) {
TextDisplay.Custom(mentionSpan)
} else {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
mentionSpan.update(mentionSpanTheme)
TextDisplay.Custom(mentionSpan)
TextDisplay.Plain
}
}
}
val resolveAtRoomMentionDisplay = remember {
{
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
TextDisplay.Custom(mentionSpan)
}
}
return MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
@ -371,6 +367,7 @@ class MessageComposerPresenter @AssistedInject constructor(
canCreatePoll = canCreatePoll.value,
suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
eventSink = { handleEvents(it) },
)
}
@ -640,8 +637,8 @@ class MessageComposerPresenter @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else {
val markdown = richTextEditorState.messageMarkdown
val pilliefiedMarkdown = pillificationHelper.pillify(markdown)
markdownTextEditorState.text.update(pilliefiedMarkdown, true)
val markdownWithMentions = pillificationHelper.pillify(markdown, false)
markdownTextEditorState.text.update(markdownWithMentions, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
@ -709,7 +706,7 @@ class MessageComposerPresenter @AssistedInject constructor(
if (content.isEmpty()) {
markdownTextEditorState.selection = IntRange.EMPTY
}
val pillifiedContent = pillificationHelper.pillify(content)
val pillifiedContent = pillificationHelper.pillify(content, false)
markdownTextEditorState.text.update(pillifiedContent, true)
if (requestFocus) {
markdownTextEditorState.requestFocusAction()

View file

@ -25,5 +25,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean,
val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val resolveAtRoomMentionDisplay: () -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)

View file

@ -43,5 +43,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
eventSink = eventSink,
)

View file

@ -113,6 +113,7 @@ internal fun MessageComposerView(
onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived,
resolveMentionDisplay = state.resolveMentionDisplay,
resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
onError = ::onError,
onTyping = ::onTyping,
onSelectRichContent = ::sendUri,

View file

@ -16,11 +16,9 @@ import androidx.compose.ui.platform.LocalInspectionMode
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
@ -29,42 +27,45 @@ import io.element.android.wysiwyg.utils.HtmlConverter
import uniffi.wysiwyg_composer.newMentionDetector
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
@ContributesBinding(RoomScope::class)
@SingleIn(RoomScope::class)
class DefaultHtmlConverterProvider @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable
override fun Update(currentUserId: UserId) {
override fun Update() {
val isInEditMode = LocalInspectionMode.current
val mentionDetector = remember(isInEditMode) {
if (isInEditMode) null else newMentionDetector()
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
htmlConverter.value = remember(editorStyle) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")
mentionSpan.update(mentionSpanTheme)
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
return TextDisplay.Custom(mentionSpan)
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
mentionSpan.update(mentionSpanTheme)
return TextDisplay.Custom(mentionSpan)
return if (mentionSpan != null) {
TextDisplay.Custom(mentionSpan)
} else {
TextDisplay.Plain
}
}
},
isEditor = false,
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
isMention = { _, url ->
mentionDetector?.isMention(url).orFalse()
}
).apply {
configureWith(editorStyle)
}

View file

@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview(
event = event.copy(
content = event.content.copy(
body = str,
pillifiedBody = str,
formattedBody = str,
),
reactionsState = aTimelineItemReactions(count = 0),
),

View file

@ -14,9 +14,6 @@ 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
@ -32,14 +29,8 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper
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.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@ -51,7 +42,7 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) &&
val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
@ -61,10 +52,10 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides textStyle
) {
val body = getTextWithResolvedMentions(content)
val text = getTextWithResolvedMentions(content)
Box(modifier.semantics { contentDescription = content.plainText }) {
EditorStyledText(
text = body,
text = text,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
@ -78,36 +69,9 @@ fun TimelineItemTextView(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
val mentionSpanTheme = LocalMentionSpanTheme.current
val formattedBody = content.formattedBody ?: content.pillifiedBody
val textWithMentions = remember(formattedBody, mentionSpanTheme, lastCacheUpdate) {
updateMentionSpans(formattedBody, userProfileCache)
mentionSpanTheme.updateMentionStyles(formattedBody)
formattedBody
}
return SpannableString(textWithMentions)
}
private fun updateMentionSpans(text: CharSequence, cache: RoomMemberProfilesCache): Boolean {
var changedContents = false
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
}
}
// There's no need to do anything for `@room` pills
MentionSpan.Type.EVERYONE -> Unit
// Nothing yet for room mentions
MentionSpan.Type.ROOM -> Unit
}
}
return changedContents
val mentionSpanUpdater = LocalMentionSpanUpdater.current
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
return SpannableString(bodyWithResolvedMentions)
}
@PreviewsDayNight
@ -126,7 +90,7 @@ internal fun TimelineItemTextViewPreview(
@Composable
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
val content = aTimelineItemTextContent(
pillifiedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.")
formattedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.")
)
TimelineItemTextView(
content = content,
@ -139,7 +103,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
@Composable
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
val content = aTimelineItemTextContent(
pillifiedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!")
formattedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!")
)
TimelineItemTextView(
content = content,

View file

@ -69,13 +69,16 @@ class TimelineItemContentMessageFactory @Inject constructor(
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
emoteBody
).safeLinkify()
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
@ -123,10 +126,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
plainText = body,
formattedBody = null,
formattedBody = body,
isEdited = content.isEdited,
)
} else {
@ -219,20 +220,26 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent(
body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
TimelineItemTextContent(
body = body,
pillifiedBody = textPillificationHelper.pillify(body).safeLinkify(),
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
@ -240,9 +247,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
formattedBody = body.withLinks(),
formattedBody = textPillificationHelper.pillify(body).safeLinkify(),
isEdited = content.isEdited,
)
}
@ -263,6 +269,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body.trimEnd())
.let { textPillificationHelper.pillify(it) }
.safeLinkify()
return if (prefix != null) {
buildSpannedString {

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
}

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import org.jsoup.nodes.Document
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf(
@ -58,35 +59,46 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineIt
)
}
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
body = "Emote",
htmlDocument = null,
formattedBody = null,
isEdited = false,
fun aTimelineItemEmoteContent(
body: String = "Emote",
htmlDocument: Document? = null,
formattedBody: CharSequence = body,
isEdited: Boolean = false,
) = TimelineItemEmoteContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
)
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown
)
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
body = "Notice",
htmlDocument = null,
formattedBody = null,
isEdited = false,
fun aTimelineItemNoticeContent(
body: String = "Notice",
htmlDocument: Document? = null,
formattedBody: CharSequence = body,
isEdited: Boolean = false,
) = TimelineItemNoticeContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
)
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
fun aTimelineItemTextContent(
body: String = "Text",
pillifiedBody: CharSequence = body,
htmlDocument: Document? = null,
formattedBody: CharSequence = body,
isEdited: Boolean = false,
) = TimelineItemTextContent(
body = body,
pillifiedBody = pillifiedBody,
htmlDocument = null,
formattedBody = null,
isEdited = false,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = isEdited,
)
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent(
override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
}

View file

@ -20,14 +20,12 @@ sealed interface TimelineItemTextBasedContent :
/** The raw body of the event, in Markdown format. */
val body: String
/** The body of the event, with mentions replaced by their pillified version. */
val pillifiedBody: CharSequence
/** The parsed HTML DOM of the formatted event body. */
val htmlDocument: Document?
/** The formatted body of the event, already parsed and with the DOM translated to Android spans. */
val formattedBody: CharSequence?
/** The formatted body of the event, already parsed and with the DOM translated to Android spans.
* This can also includes mention spans from permalink parsing */
val formattedBody: CharSequence
/** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
val plainText: String

View file

@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent(
override val body: String,
override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
}

View file

@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.utils
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan
import android.util.Patterns
import androidx.core.text.getSpans
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
@ -16,63 +19,90 @@ import io.element.android.libraries.matrix.api.core.MatrixPatternType
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.view.spans.CodeBlockSpan
import io.element.android.wysiwyg.view.spans.InlineCodeSpan
import javax.inject.Inject
interface TextPillificationHelper {
fun pillify(text: CharSequence): CharSequence
fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence
}
@ContributesBinding(RoomScope::class)
class DefaultTextPillificationHelper @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
private val permalinkBuilder: PermalinkBuilder,
private val permalinkParser: PermalinkParser,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val permalinkBuilder: PermalinkBuilder,
) : TextPillificationHelper {
@Suppress("LoopWithTooManyJumpStatements")
override fun pillify(text: CharSequence): CharSequence {
val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
if (matches.isEmpty()) return text
override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
return SpannableStringBuilder(text).apply {
pillifyMatrixPatterns(this)
if (pillifyPermalinks) {
pillifyPermalinks(this)
}
}
}
val spannable = SpannableStringBuilder(text)
private fun pillifyMatrixPatterns(text: SpannableStringBuilder) {
val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
if (matches.isEmpty()) return
for (match in matches) {
if (!text.canPillify(match.start, match.end)) continue
when (match.type) {
MatrixPatternType.USER_ID -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
if (!mentionSpanExists) {
val userId = UserId(match.value)
val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it }
spannable.replace(match.start, match.end, "@ ")
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val userId = UserId(match.value)
val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
text.replace(match.start, match.end, "@ ")
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink ->
// Also add a URLSpan in case of raw user id so it can be clicked
val urlSpan = URLSpan(permalink)
text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MatrixPatternType.ROOM_ALIAS -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
if (!mentionSpanExists) {
val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
spannable.replace(match.start, match.end, "@ ")
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val roomAlias = RoomAlias(match.value)
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
text.replace(match.start, match.end, "@ ")
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink ->
// Also add a URLSpan in case of raw room alias so it can be clicked
val urlSpan = URLSpan(permalink)
text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MatrixPatternType.AT_ROOM -> {
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
if (!mentionSpanExists) {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "")
spannable.replace(match.start, match.end, "@ ")
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
text.replace(match.start, match.end, "@ ")
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
else -> Unit
}
}
return spannable
}
private fun pillifyPermalinks(text: SpannableStringBuilder) {
for (match in Patterns.WEB_URL.toRegex().findAll(text)) {
val start = match.range.first
val end = match.range.last + 1
if (!text.canPillify(start, end)) continue
val url = text.substring(match.range)
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, url)
if (mentionSpan != null) {
text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
private fun Spanned.canPillify(start: Int, end: Int): Boolean {
if (getMentionSpans(start, end).isNotEmpty()) return false
if (getSpans<CodeBlockSpan>(start, end).isNotEmpty()) return false
if (getSpans<InlineCodeSpan>(start, end).isNotEmpty()) return false
return true
}
}