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:
parent
a962479788
commit
042c0c5a6b
48 changed files with 1156 additions and 568 deletions
|
|
@ -8,12 +8,11 @@
|
|||
package io.element.android.features.messages.api.timeline
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||
|
||||
interface HtmlConverterProvider {
|
||||
@Composable
|
||||
fun Update(currentUserId: UserId)
|
||||
fun Update()
|
||||
|
||||
fun provide(): HtmlConverter
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions(
|
|||
onDeleteVoiceMessage = {},
|
||||
onReceiveSuggestion = {},
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
onError = {},
|
||||
onTyping = {},
|
||||
onSelectRichContent = {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,5 +43,6 @@ fun aMessageComposerState(
|
|||
canCreatePoll = canCreatePoll,
|
||||
suggestions = suggestions,
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ internal fun MessageComposerView(
|
|||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
onReceiveSuggestion = ::onSuggestionReceived,
|
||||
resolveMentionDisplay = state.resolveMentionDisplay,
|
||||
resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
|
||||
onError = ::onError,
|
||||
onTyping = ::onTyping,
|
||||
onSelectRichContent = ::sendUri,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview(
|
|||
event = event.copy(
|
||||
content = event.content.copy(
|
||||
body = str,
|
||||
pillifiedBody = str,
|
||||
formattedBody = str,
|
||||
),
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
isEditable = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -201,7 +201,7 @@ class ActionListPresenterTest {
|
|||
isMine = false,
|
||||
isEditable = false,
|
||||
isThreaded = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -248,7 +248,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
isEditable = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -296,7 +296,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
isEditable = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -344,7 +344,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
isEditable = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -391,7 +391,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -439,7 +439,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
isThreaded = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -486,7 +486,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -804,7 +804,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -852,7 +852,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -907,7 +907,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
|
|
@ -956,7 +956,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
|
||||
)
|
||||
val redactedEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
|
|
@ -1006,7 +1006,7 @@ class ActionListPresenterTest {
|
|||
eventId = null,
|
||||
isMine = true,
|
||||
canBeRepliedTo = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE),
|
||||
)
|
||||
|
||||
initialState.eventSink.invoke(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ internal fun aMessageEvent(
|
|||
isMine: Boolean = true,
|
||||
isEditable: Boolean = true,
|
||||
canBeRepliedTo: Boolean = true,
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
|
||||
inReplyTo: InReplyToDetails? = null,
|
||||
isThreaded: Boolean = false,
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.draft.ComposerDraftService
|
|||
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
|
@ -67,7 +68,6 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
|||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
|
|
@ -82,6 +82,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
|
|||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
|
|
@ -1535,8 +1536,11 @@ class MessageComposerPresenterTest {
|
|||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser),
|
||||
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
|
||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = permalinkParser,
|
||||
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||
),
|
||||
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
|
|
@ -1562,7 +1566,6 @@ class MessageComposerPresenterTest {
|
|||
draftService = draftService,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
pillificationHelper = textPillificationHelper,
|
||||
roomMemberProfilesCache = roomMemberProfilesCache,
|
||||
suggestionsProcessor = SuggestionsProcessor(),
|
||||
).apply {
|
||||
isTesting = true
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -23,10 +25,16 @@ import org.robolectric.RobolectricTestRunner
|
|||
class DefaultHtmlConverterProviderTest {
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
private val provider = DefaultHtmlConverterProvider(
|
||||
mentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `calling provide without calling Update first should throw an exception`() {
|
||||
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
|
||||
|
||||
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
||||
|
||||
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
||||
|
|
@ -34,13 +42,11 @@ class DefaultHtmlConverterProviderTest {
|
|||
|
||||
@Test
|
||||
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||
provider.Update(currentUserId = A_USER_ID)
|
||||
provider.Update()
|
||||
}
|
||||
}
|
||||
|
||||
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
||||
|
||||
assertThat(htmlConverter).isNotNull()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannedString
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
|
|
@ -18,16 +19,22 @@ 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.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
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.matrix.ui.messages.RoomNamesCache
|
||||
import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanUpdater
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -40,101 +47,100 @@ import org.junit.runner.RunWith
|
|||
class TimelineTextViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID)
|
||||
private val formatLambda = lambdaRecorder<MentionType, CharSequence> { mentionType -> mentionType.toString() }
|
||||
private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda)
|
||||
|
||||
@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))
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
assertThat(result.getMentionSpans()).isEmpty()
|
||||
assert(formatLambda).isNeverCalled()
|
||||
}
|
||||
|
||||
@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))
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
assertThat(result.getMentionSpans()).isEmpty()
|
||||
assert(formatLambda).isNeverCalled()
|
||||
}
|
||||
|
||||
@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))
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
|
||||
|
||||
assertThat(result.getMentionSpans()).isEmpty()
|
||||
assertThat(result.toString()).isEqualTo(charSequence)
|
||||
assert(formatLambda).isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTextWithResolvedMentions - with Room mention does nothing`() = runTest {
|
||||
fun `getTextWithResolvedMentions - with Room mention format correctly`() = runTest {
|
||||
val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias())
|
||||
val charSequence = buildSpannedString {
|
||||
append("Hello ")
|
||||
inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) {
|
||||
inSpans(MentionSpan(mentionType)) {
|
||||
append(A_ROOM_ID.value)
|
||||
}
|
||||
}
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
|
||||
val expectedDisplayText = mentionType.toString()
|
||||
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||
assertThat(result).isEqualTo(charSequence)
|
||||
assert(formatLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
|
||||
val mentionType = MentionType.User(userId = A_USER_ID)
|
||||
val charSequence = buildSpannedString {
|
||||
append("Hello ")
|
||||
inSpans(aMentionSpan(rawValue = A_USER_ID.value)) {
|
||||
inSpans(MentionSpan(mentionType)) {
|
||||
append("@NotAlice")
|
||||
}
|
||||
}
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
||||
|
||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
|
||||
val expectedDisplayText = mentionType.toString()
|
||||
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||
assert(formatLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
|
||||
val mentionType = MentionType.User(userId = A_USER_ID)
|
||||
val charSequence = buildSpannedString {
|
||||
append("Hello ")
|
||||
inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) {
|
||||
inSpans(CustomMentionSpan(MentionSpan(mentionType))) {
|
||||
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)
|
||||
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||
val expectedDisplayText = mentionType.toString()
|
||||
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||
assert(formatLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
|
||||
mentionSpanUpdater: MentionSpanUpdater,
|
||||
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,
|
||||
LocalMentionSpanUpdater provides mentionSpanUpdater
|
||||
) {
|
||||
completable.complete(getTextWithResolvedMentions(content = content))
|
||||
}
|
||||
|
|
@ -142,21 +148,20 @@ class TimelineTextViewTest {
|
|||
return completable.await()
|
||||
}
|
||||
|
||||
private fun aMentionSpan(
|
||||
rawValue: String,
|
||||
text: String = "",
|
||||
type: MentionSpan.Type = MentionSpan.Type.USER
|
||||
) = MentionSpan(
|
||||
text = text,
|
||||
rawValue = rawValue,
|
||||
type = type,
|
||||
)
|
||||
private fun aMentionSpanUpdater(): MentionSpanUpdater {
|
||||
return DefaultMentionSpanUpdater(
|
||||
formatter = mentionSpanFormatter,
|
||||
theme = mentionSpanTheme,
|
||||
roomMemberProfilesCache = RoomMemberProfilesCache(),
|
||||
roomNamesCache = RoomNamesCache(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = null,
|
||||
formattedBody = formattedBody,
|
||||
formattedBody = formattedBody ?: SpannedString(body),
|
||||
isEdited = false
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,9 +89,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemTextContent(
|
||||
body = "body",
|
||||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
formattedBody = SpannedString("body"),
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -123,9 +122,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemTextContent(
|
||||
body = "body",
|
||||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
formattedBody = "body",
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -141,10 +139,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemTextContent(
|
||||
body = "body",
|
||||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
pillifiedBody = SpannableString("body"),
|
||||
formattedBody = SpannedString("body"),
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -160,7 +156,6 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemTextContent(
|
||||
body = "https://www.example.org",
|
||||
htmlDocument = null,
|
||||
plainText = "https://www.example.org",
|
||||
isEdited = false,
|
||||
formattedBody = buildSpannedString {
|
||||
inSpans(URLSpan("https://www.example.org")) {
|
||||
|
|
@ -223,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -637,8 +632,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemNoticeContent(
|
||||
body = "body",
|
||||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
formattedBody = null,
|
||||
formattedBody = SpannedString("body"),
|
||||
isEdited = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
@ -671,8 +665,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val expected = TimelineItemEmoteContent(
|
||||
body = "* Bob body",
|
||||
htmlDocument = null,
|
||||
plainText = "* Bob body",
|
||||
formattedBody = null,
|
||||
formattedBody = SpannedString("* Bob body"),
|
||||
isEdited = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
|
|
|||
|
|
@ -10,17 +10,21 @@ package io.element.android.features.messages.impl.utils
|
|||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -30,90 +34,201 @@ class DefaultTextPillificationHelperTest {
|
|||
@Test
|
||||
fun `pillify - adds pills for user ids`() {
|
||||
val text = "A @user:server.com"
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val userId = UserId("@user:server.com")
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkparser = FakePermalinkParser(result = {
|
||||
PermalinkData.UserLink(UserId("@user:server.com"))
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.UserLink(userId)
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
||||
Result.success("https://matrix.to/#/@user:server.com")
|
||||
}),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.firstOrNull()
|
||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
|
||||
assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
|
||||
assertThat(mentionSpan?.text).isEqualTo("@user:server.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - uses the cached display name for user mentions`() {
|
||||
val text = "A @user:server.com"
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkparser = FakePermalinkParser(result = {
|
||||
PermalinkData.UserLink(UserId("@user:server.com"))
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
||||
Result.success("https://matrix.to/#/@user:server.com")
|
||||
}),
|
||||
roomMemberProfilesCache = RoomMemberProfilesCache().apply {
|
||||
replace(listOf(aRoomMember(userId = UserId("@user:server.com"), displayName = "Alice")))
|
||||
},
|
||||
)
|
||||
val pillified = helper.pillify(text)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.firstOrNull()
|
||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
|
||||
assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
|
||||
assertThat(mentionSpan?.text).isEqualTo("Alice")
|
||||
val mentionSpan = mentionSpans.first()
|
||||
assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
|
||||
val userType = mentionSpan.type as MentionType.User
|
||||
assertThat(userType.userId).isEqualTo(userId)
|
||||
val formatted = formatter.formatDisplayText(MentionType.User(userId))
|
||||
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - adds pills for room aliases`() {
|
||||
val text = "A #room:server.com"
|
||||
val roomAlias = RoomAlias("#room:server.com")
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkparser = FakePermalinkParser(result = {
|
||||
PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com")))
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias))
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
|
||||
Result.success("https://matrix.to/#/#room:server.com")
|
||||
}),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.firstOrNull()
|
||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM)
|
||||
assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com")
|
||||
assertThat(mentionSpan?.text).isEqualTo("#room:server.com")
|
||||
val mentionSpan = mentionSpans.first()
|
||||
assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java)
|
||||
val roomType = mentionSpan.type as MentionType.Room
|
||||
assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias())
|
||||
val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias()))
|
||||
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - adds pills for @room mentions`() {
|
||||
val text = "An @room mention"
|
||||
val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = {
|
||||
PermalinkData.FallbackLink(Uri.EMPTY)
|
||||
}))
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.FallbackLink(Uri.EMPTY)
|
||||
}),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.firstOrNull()
|
||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE)
|
||||
assertThat(mentionSpan?.rawValue).isEqualTo("@room")
|
||||
assertThat(mentionSpan?.text).isEqualTo("@room")
|
||||
val mentionSpan = mentionSpans.first()
|
||||
assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone)
|
||||
val formatted = formatter.formatDisplayText(MentionType.Everyone)
|
||||
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - adds pills for message permalinks`() {
|
||||
val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
|
||||
val roomId = RoomId("!roomid:server.com")
|
||||
val eventId = EventId("$123")
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.RoomLink(
|
||||
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
|
||||
eventId = eventId
|
||||
)
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.first()
|
||||
assertThat(mentionSpan.type).isInstanceOf(MentionType.Message::class.java)
|
||||
val messageType = mentionSpan.type as MentionType.Message
|
||||
assertThat(messageType.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
|
||||
assertThat(messageType.eventId).isEqualTo(eventId)
|
||||
val formatted = formatter.formatDisplayText(MentionType.Message(roomId.toRoomIdOrAlias(), eventId))
|
||||
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - with pillifyPermalinks false does not add pills for permalinks`() {
|
||||
val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
|
||||
val roomId = RoomId("!roomid:server.com")
|
||||
val eventId = EventId("$123")
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.RoomLink(
|
||||
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
|
||||
eventId = eventId
|
||||
)
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text, pillifyPermalinks = false)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - with pillifyPermalinks false still adds pills for matrix patterns`() {
|
||||
val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
|
||||
val userId = UserId("@user:server.com")
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkParser = FakePermalinkParser(result = {
|
||||
PermalinkData.UserLink(userId)
|
||||
}),
|
||||
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
||||
Result.success("https://matrix.to/#/@user:server.com")
|
||||
}),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text, pillifyPermalinks = false)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(1)
|
||||
val mentionSpan = mentionSpans.first()
|
||||
assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
|
||||
val userType = mentionSpan.type as MentionType.User
|
||||
assertThat(userType.userId).isEqualTo(userId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pillify - with pillifyPermalinks true adds pills for both matrix patterns and permalinks`() {
|
||||
val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
|
||||
val userId = UserId("@user:server.com")
|
||||
val roomId = RoomId("!roomid:server.com")
|
||||
val eventId = EventId("$123")
|
||||
val formatter = FakeMentionSpanFormatter()
|
||||
val permalinkParser = FakePermalinkParser(result = { url ->
|
||||
if (url.contains("matrix.to")) {
|
||||
PermalinkData.RoomLink(
|
||||
roomIdOrAlias = RoomIdOrAlias.Id(roomId),
|
||||
eventId = eventId
|
||||
)
|
||||
} else {
|
||||
PermalinkData.UserLink(userId)
|
||||
}
|
||||
})
|
||||
val helper = aTextPillificationHelper(
|
||||
permalinkParser = permalinkParser,
|
||||
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
||||
Result.success("https://matrix.to/#/@user:server.com")
|
||||
}),
|
||||
mentionSpanFormatter = formatter,
|
||||
)
|
||||
val pillified = helper.pillify(text, pillifyPermalinks = true)
|
||||
val mentionSpans = pillified.getMentionSpans()
|
||||
assertThat(mentionSpans).hasSize(2)
|
||||
|
||||
// Check that we have both a user mention and a message mention
|
||||
val types = mentionSpans.map { it.type::class.java }
|
||||
assertThat(types).contains(MentionType.User::class.java)
|
||||
assertThat(types).contains(MentionType.Message::class.java)
|
||||
|
||||
// Verify the user mention
|
||||
val userMention = mentionSpans.first { it.type is MentionType.User }.type as MentionType.User
|
||||
assertThat(userMention.userId).isEqualTo(userId)
|
||||
|
||||
// Verify the message mention
|
||||
val messageMention = mentionSpans.first { it.type is MentionType.Message }.type as MentionType.Message
|
||||
assertThat(messageMention.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
|
||||
assertThat(messageMention.eventId).isEqualTo(eventId)
|
||||
}
|
||||
|
||||
private fun aTextPillificationHelper(
|
||||
permalinkparser: PermalinkParser = FakePermalinkParser(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
|
||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser),
|
||||
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
|
||||
) = DefaultTextPillificationHelper(
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
permalinkParser = permalinkparser,
|
||||
roomMemberProfilesCache = roomMemberProfilesCache,
|
||||
)
|
||||
mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||
): TextPillificationHelper {
|
||||
val mentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = permalinkParser,
|
||||
mentionSpanFormatter = mentionSpanFormatter,
|
||||
mentionSpanTheme = MentionSpanTheme(A_USER_ID),
|
||||
)
|
||||
return DefaultTextPillificationHelper(
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.utils
|
||||
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||
|
||||
class FakeMentionSpanFormatter(
|
||||
private val formatLambda: (MentionType) -> CharSequence = { type -> type.toString() },
|
||||
) : MentionSpanFormatter {
|
||||
override fun formatDisplayText(mentionType: MentionType): CharSequence {
|
||||
return formatLambda(mentionType)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
package io.element.android.features.messages.impl.utils
|
||||
|
||||
class FakeTextPillificationHelper(
|
||||
private val pillifyLambda: (CharSequence) -> CharSequence = { it }
|
||||
private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text }
|
||||
) : TextPillificationHelper {
|
||||
override fun pillify(text: CharSequence): CharSequence {
|
||||
return pillifyLambda(text)
|
||||
override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
|
||||
return pillifyLambda(text, pillifyPermalinks)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,13 @@ package io.element.android.features.messages.test.timeline
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||
|
||||
class FakeHtmlConverterProvider(
|
||||
private val transform: (String) -> CharSequence = { it },
|
||||
) : HtmlConverterProvider {
|
||||
@Composable
|
||||
override fun Update(currentUserId: UserId) = Unit
|
||||
override fun Update() = Unit
|
||||
|
||||
override fun provide(): HtmlConverter {
|
||||
return object : HtmlConverter {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue