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
|
|
@ -241,7 +241,7 @@ class IntentResolverTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createIntentResolver(
|
private fun createIntentResolver(
|
||||||
permalinkParserResult: () -> PermalinkData = { lambdaError() }
|
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
|
||||||
): IntentResolver {
|
): IntentResolver {
|
||||||
return IntentResolver(
|
return IntentResolver(
|
||||||
deeplinkParser = DeeplinkParser(),
|
deeplinkParser = DeeplinkParser(),
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@
|
||||||
package io.element.android.features.messages.api.timeline
|
package io.element.android.features.messages.api.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.wysiwyg.utils.HtmlConverter
|
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||||
|
|
||||||
interface HtmlConverterProvider {
|
interface HtmlConverterProvider {
|
||||||
@Composable
|
@Composable
|
||||||
fun Update(currentUserId: UserId)
|
fun Update()
|
||||||
|
|
||||||
fun provide(): HtmlConverter
|
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.Overlay
|
||||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||||
import io.element.android.libraries.architecture.overlay.operation.show
|
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.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
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.room.joinedRoomMembers
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
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.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.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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
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.MentionSpanTheme
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
|
|
@ -100,11 +103,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
private val locationService: LocationService,
|
private val locationService: LocationService,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||||
|
private val roomNamesCache: RoomNamesCache,
|
||||||
|
private val mentionSpanUpdater: MentionSpanUpdater,
|
||||||
private val mentionSpanTheme: MentionSpanTheme,
|
private val mentionSpanTheme: MentionSpanTheme,
|
||||||
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
||||||
private val timelineController: TimelineController,
|
private val timelineController: TimelineController,
|
||||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
||||||
|
|
@ -172,13 +178,29 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
timelineController.close()
|
timelineController.close()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
setupCacheUpdaters()
|
||||||
|
|
||||||
|
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupCacheUpdaters() {
|
||||||
room.membersStateFlow
|
room.membersStateFlow
|
||||||
.onEach { membersState ->
|
.onEach { membersState ->
|
||||||
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
|
withContext(coroutineDispatchers.computation) {
|
||||||
|
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.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 {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -465,10 +487,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
|
mentionSpanTheme.updateStyles()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
|
LocalMentionSpanUpdater provides mentionSpanUpdater
|
||||||
LocalMentionSpanTheme provides mentionSpanTheme,
|
|
||||||
) {
|
) {
|
||||||
BackstackWithOverlayBox(modifier)
|
BackstackWithOverlayBox(modifier)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
htmlConverterProvider.Update(currentUserId = room.sessionId)
|
htmlConverterProvider.Update()
|
||||||
|
|
||||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions(
|
||||||
onDeleteVoiceMessage = {},
|
onDeleteVoiceMessage = {},
|
||||||
onReceiveSuggestion = {},
|
onReceiveSuggestion = {},
|
||||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||||
|
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||||
onError = {},
|
onError = {},
|
||||||
onTyping = {},
|
onTyping = {},
|
||||||
onSelectRichContent = {},
|
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.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
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.permalink.PermalinkParser
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.room.isDm
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
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.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.InReplyToDetails
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
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.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||||
|
|
@ -119,7 +116,6 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
private val draftService: ComposerDraftService,
|
private val draftService: ComposerDraftService,
|
||||||
private val mentionSpanProvider: MentionSpanProvider,
|
private val mentionSpanProvider: MentionSpanProvider,
|
||||||
private val pillificationHelper: TextPillificationHelper,
|
private val pillificationHelper: TextPillificationHelper,
|
||||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
|
||||||
private val suggestionsProcessor: SuggestionsProcessor,
|
private val suggestionsProcessor: SuggestionsProcessor,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -331,7 +327,6 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
markdownTextEditorState.insertSuggestion(
|
markdownTextEditorState.insertSuggestion(
|
||||||
resolvedSuggestion = event.resolvedSuggestion,
|
resolvedSuggestion = event.resolvedSuggestion,
|
||||||
mentionSpanProvider = mentionSpanProvider,
|
mentionSpanProvider = mentionSpanProvider,
|
||||||
permalinkBuilder = permalinkBuilder,
|
|
||||||
)
|
)
|
||||||
suggestionSearchTrigger.value = null
|
suggestionSearchTrigger.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -344,23 +339,24 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mentionSpanTheme = LocalMentionSpanTheme.current
|
val resolveMentionDisplay = remember {
|
||||||
val resolveMentionDisplay = remember(mentionSpanTheme) {
|
|
||||||
{ text: String, url: String ->
|
{ text: String, url: String ->
|
||||||
val permalinkData = permalinkParser.parse(url)
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
|
||||||
if (permalinkData is PermalinkData.UserLink) {
|
if (mentionSpan != null) {
|
||||||
val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
|
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)
|
|
||||||
mentionSpan.update(mentionSpanTheme)
|
|
||||||
TextDisplay.Custom(mentionSpan)
|
TextDisplay.Custom(mentionSpan)
|
||||||
} else {
|
} else {
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
|
TextDisplay.Plain
|
||||||
mentionSpan.update(mentionSpanTheme)
|
|
||||||
TextDisplay.Custom(mentionSpan)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val resolveAtRoomMentionDisplay = remember {
|
||||||
|
{
|
||||||
|
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
|
||||||
|
TextDisplay.Custom(mentionSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
|
|
@ -371,6 +367,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
canCreatePoll = canCreatePoll.value,
|
canCreatePoll = canCreatePoll.value,
|
||||||
suggestions = suggestions.toPersistentList(),
|
suggestions = suggestions.toPersistentList(),
|
||||||
resolveMentionDisplay = resolveMentionDisplay,
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
|
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||||
eventSink = { handleEvents(it) },
|
eventSink = { handleEvents(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -640,8 +637,8 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
|
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
|
||||||
} else {
|
} else {
|
||||||
val markdown = richTextEditorState.messageMarkdown
|
val markdown = richTextEditorState.messageMarkdown
|
||||||
val pilliefiedMarkdown = pillificationHelper.pillify(markdown)
|
val markdownWithMentions = pillificationHelper.pillify(markdown, false)
|
||||||
markdownTextEditorState.text.update(pilliefiedMarkdown, true)
|
markdownTextEditorState.text.update(markdownWithMentions, true)
|
||||||
// Give some time for the focus of the previous editor to be cleared
|
// Give some time for the focus of the previous editor to be cleared
|
||||||
delay(100)
|
delay(100)
|
||||||
markdownTextEditorState.requestFocusAction()
|
markdownTextEditorState.requestFocusAction()
|
||||||
|
|
@ -709,7 +706,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
if (content.isEmpty()) {
|
if (content.isEmpty()) {
|
||||||
markdownTextEditorState.selection = IntRange.EMPTY
|
markdownTextEditorState.selection = IntRange.EMPTY
|
||||||
}
|
}
|
||||||
val pillifiedContent = pillificationHelper.pillify(content)
|
val pillifiedContent = pillificationHelper.pillify(content, false)
|
||||||
markdownTextEditorState.text.update(pillifiedContent, true)
|
markdownTextEditorState.text.update(pillifiedContent, true)
|
||||||
if (requestFocus) {
|
if (requestFocus) {
|
||||||
markdownTextEditorState.requestFocusAction()
|
markdownTextEditorState.requestFocusAction()
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,6 @@ data class MessageComposerState(
|
||||||
val canCreatePoll: Boolean,
|
val canCreatePoll: Boolean,
|
||||||
val suggestions: ImmutableList<ResolvedSuggestion>,
|
val suggestions: ImmutableList<ResolvedSuggestion>,
|
||||||
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
||||||
|
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit,
|
val eventSink: (MessageComposerEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,6 @@ fun aMessageComposerState(
|
||||||
canCreatePoll = canCreatePoll,
|
canCreatePoll = canCreatePoll,
|
||||||
suggestions = suggestions,
|
suggestions = suggestions,
|
||||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||||
|
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ internal fun MessageComposerView(
|
||||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||||
onReceiveSuggestion = ::onSuggestionReceived,
|
onReceiveSuggestion = ::onSuggestionReceived,
|
||||||
resolveMentionDisplay = state.resolveMentionDisplay,
|
resolveMentionDisplay = state.resolveMentionDisplay,
|
||||||
|
resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
|
||||||
onError = ::onError,
|
onError = ::onError,
|
||||||
onTyping = ::onTyping,
|
onTyping = ::onTyping,
|
||||||
onSelectRichContent = ::sendUri,
|
onSelectRichContent = ::sendUri,
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,9 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
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.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
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.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
import io.element.android.wysiwyg.compose.StyledHtmlConverter
|
import io.element.android.wysiwyg.compose.StyledHtmlConverter
|
||||||
import io.element.android.wysiwyg.display.MentionDisplayHandler
|
import io.element.android.wysiwyg.display.MentionDisplayHandler
|
||||||
|
|
@ -29,42 +27,45 @@ import io.element.android.wysiwyg.utils.HtmlConverter
|
||||||
import uniffi.wysiwyg_composer.newMentionDetector
|
import uniffi.wysiwyg_composer.newMentionDetector
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(RoomScope::class)
|
||||||
@SingleIn(SessionScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class DefaultHtmlConverterProvider @Inject constructor(
|
class DefaultHtmlConverterProvider @Inject constructor(
|
||||||
private val mentionSpanProvider: MentionSpanProvider,
|
private val mentionSpanProvider: MentionSpanProvider,
|
||||||
) : HtmlConverterProvider {
|
) : HtmlConverterProvider {
|
||||||
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
|
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Update(currentUserId: UserId) {
|
override fun Update() {
|
||||||
val isInEditMode = LocalInspectionMode.current
|
val isInEditMode = LocalInspectionMode.current
|
||||||
val mentionDetector = remember(isInEditMode) {
|
val mentionDetector = remember(isInEditMode) {
|
||||||
if (isInEditMode) null else newMentionDetector()
|
if (isInEditMode) null else newMentionDetector()
|
||||||
}
|
}
|
||||||
|
|
||||||
val editorStyle = ElementRichTextEditorStyle.textStyle()
|
val editorStyle = ElementRichTextEditorStyle.textStyle()
|
||||||
val mentionSpanTheme = LocalMentionSpanTheme.current
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
|
htmlConverter.value = remember(editorStyle) {
|
||||||
StyledHtmlConverter(
|
StyledHtmlConverter(
|
||||||
context = context,
|
context = context,
|
||||||
mentionDisplayHandler = object : MentionDisplayHandler {
|
mentionDisplayHandler = object : MentionDisplayHandler {
|
||||||
override fun resolveAtRoomMentionDisplay(): TextDisplay {
|
override fun resolveAtRoomMentionDisplay(): TextDisplay {
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")
|
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
|
||||||
mentionSpan.update(mentionSpanTheme)
|
|
||||||
return TextDisplay.Custom(mentionSpan)
|
return TextDisplay.Custom(mentionSpan)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
|
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
|
||||||
mentionSpan.update(mentionSpanTheme)
|
return if (mentionSpan != null) {
|
||||||
return TextDisplay.Custom(mentionSpan)
|
TextDisplay.Custom(mentionSpan)
|
||||||
|
} else {
|
||||||
|
TextDisplay.Plain
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isEditor = false,
|
isEditor = false,
|
||||||
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
|
isMention = { _, url ->
|
||||||
|
mentionDetector?.isMention(url).orFalse()
|
||||||
|
}
|
||||||
).apply {
|
).apply {
|
||||||
configureWith(editorStyle)
|
configureWith(editorStyle)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview(
|
||||||
event = event.copy(
|
event = event.copy(
|
||||||
content = event.content.copy(
|
content = event.content.copy(
|
||||||
body = str,
|
body = str,
|
||||||
pillifiedBody = str,
|
formattedBody = str,
|
||||||
),
|
),
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
|
@ -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.androidutils.text.LinkifyHelper
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
|
|
||||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||||
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.wysiwyg.compose.EditorStyledText
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
|
||||||
|
|
@ -51,7 +42,7 @@ fun TimelineItemTextView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) &&
|
val emojiOnly = content.formattedBody.toString() == content.body &&
|
||||||
content.body.replace(" ", "").containsOnlyEmojis()
|
content.body.replace(" ", "").containsOnlyEmojis()
|
||||||
val textStyle = when {
|
val textStyle = when {
|
||||||
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
|
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
|
||||||
|
|
@ -61,10 +52,10 @@ fun TimelineItemTextView(
|
||||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||||
LocalTextStyle provides textStyle
|
LocalTextStyle provides textStyle
|
||||||
) {
|
) {
|
||||||
val body = getTextWithResolvedMentions(content)
|
val text = getTextWithResolvedMentions(content)
|
||||||
Box(modifier.semantics { contentDescription = content.plainText }) {
|
Box(modifier.semantics { contentDescription = content.plainText }) {
|
||||||
EditorStyledText(
|
EditorStyledText(
|
||||||
text = body,
|
text = text,
|
||||||
onLinkClickedListener = onLinkClick,
|
onLinkClickedListener = onLinkClick,
|
||||||
onLinkLongClickedListener = onLinkLongClick,
|
onLinkLongClickedListener = onLinkLongClick,
|
||||||
style = ElementRichTextEditorStyle.textStyle(),
|
style = ElementRichTextEditorStyle.textStyle(),
|
||||||
|
|
@ -78,36 +69,9 @@ fun TimelineItemTextView(
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
||||||
val userProfileCache = LocalRoomMemberProfilesCache.current
|
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
||||||
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
|
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
|
||||||
val mentionSpanTheme = LocalMentionSpanTheme.current
|
return SpannableString(bodyWithResolvedMentions)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
@ -126,7 +90,7 @@ internal fun TimelineItemTextViewPreview(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
|
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
|
||||||
val content = aTimelineItemTextContent(
|
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(
|
TimelineItemTextView(
|
||||||
content = content,
|
content = content,
|
||||||
|
|
@ -139,7 +103,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
|
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
|
||||||
val content = aTimelineItemTextContent(
|
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(
|
TimelineItemTextView(
|
||||||
content = content,
|
content = content,
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,16 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
return when (val messageType = content.type) {
|
return when (val messageType = content.type) {
|
||||||
is EmoteMessageType -> {
|
is EmoteMessageType -> {
|
||||||
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
||||||
|
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
|
||||||
|
emoteBody
|
||||||
|
).safeLinkify()
|
||||||
TimelineItemEmoteContent(
|
TimelineItemEmoteContent(
|
||||||
body = emoteBody,
|
body = emoteBody,
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(
|
htmlDocument = messageType.formatted?.toHtmlDocument(
|
||||||
permalinkParser = permalinkParser,
|
permalinkParser = permalinkParser,
|
||||||
prefix = "* $senderDisambiguatedDisplayName",
|
prefix = "* $senderDisambiguatedDisplayName",
|
||||||
),
|
),
|
||||||
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
|
formattedBody = formattedBody,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -123,10 +126,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
pillifiedBody = textPillificationHelper.pillify(body),
|
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = body,
|
formattedBody = body,
|
||||||
formattedBody = null,
|
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,20 +220,26 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
}
|
}
|
||||||
is NoticeMessageType -> {
|
is NoticeMessageType -> {
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
|
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
|
||||||
|
body
|
||||||
|
).safeLinkify()
|
||||||
|
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||||
TimelineItemNoticeContent(
|
TimelineItemNoticeContent(
|
||||||
body = body,
|
body = body,
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
|
htmlDocument = htmlDocument,
|
||||||
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
|
formattedBody = formattedBody,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TextMessageType -> {
|
is TextMessageType -> {
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
|
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
|
||||||
|
body
|
||||||
|
).safeLinkify()
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
pillifiedBody = textPillificationHelper.pillify(body).safeLinkify(),
|
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
|
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
|
||||||
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
|
formattedBody = formattedBody,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -240,9 +247,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
pillifiedBody = textPillificationHelper.pillify(body),
|
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
formattedBody = body.withLinks(),
|
formattedBody = textPillificationHelper.pillify(body).safeLinkify(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +269,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
|
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
|
||||||
val result = htmlConverterProvider.provide()
|
val result = htmlConverterProvider.provide()
|
||||||
.fromHtmlToSpans(formattedBody.body.trimEnd())
|
.fromHtmlToSpans(formattedBody.body.trimEnd())
|
||||||
|
.let { textPillificationHelper.pillify(it) }
|
||||||
.safeLinkify()
|
.safeLinkify()
|
||||||
return if (prefix != null) {
|
return if (prefix != null) {
|
||||||
buildSpannedString {
|
buildSpannedString {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemEmoteContent(
|
data class TimelineItemEmoteContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val pillifiedBody: CharSequence = body,
|
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val formattedBody: CharSequence,
|
||||||
override val formattedBody: CharSequence?,
|
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemEmoteContent"
|
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.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
|
|
@ -58,35 +59,46 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineIt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
|
fun aTimelineItemEmoteContent(
|
||||||
body = "Emote",
|
body: String = "Emote",
|
||||||
htmlDocument = null,
|
htmlDocument: Document? = null,
|
||||||
formattedBody = null,
|
formattedBody: CharSequence = body,
|
||||||
isEdited = false,
|
isEdited: Boolean = false,
|
||||||
|
) = TimelineItemEmoteContent(
|
||||||
|
body = body,
|
||||||
|
htmlDocument = htmlDocument,
|
||||||
|
formattedBody = formattedBody,
|
||||||
|
isEdited = isEdited,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
|
fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
|
||||||
data = UnableToDecryptContent.Data.Unknown
|
data = UnableToDecryptContent.Data.Unknown
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
|
fun aTimelineItemNoticeContent(
|
||||||
body = "Notice",
|
body: String = "Notice",
|
||||||
htmlDocument = null,
|
htmlDocument: Document? = null,
|
||||||
formattedBody = null,
|
formattedBody: CharSequence = body,
|
||||||
isEdited = false,
|
isEdited: Boolean = false,
|
||||||
|
) = TimelineItemNoticeContent(
|
||||||
|
body = body,
|
||||||
|
htmlDocument = htmlDocument,
|
||||||
|
formattedBody = formattedBody,
|
||||||
|
isEdited = isEdited,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
|
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
|
||||||
|
|
||||||
fun aTimelineItemTextContent(
|
fun aTimelineItemTextContent(
|
||||||
body: String = "Text",
|
body: String = "Text",
|
||||||
pillifiedBody: CharSequence = body,
|
htmlDocument: Document? = null,
|
||||||
|
formattedBody: CharSequence = body,
|
||||||
|
isEdited: Boolean = false,
|
||||||
) = TimelineItemTextContent(
|
) = TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
pillifiedBody = pillifiedBody,
|
htmlDocument = htmlDocument,
|
||||||
htmlDocument = null,
|
formattedBody = formattedBody,
|
||||||
formattedBody = null,
|
isEdited = isEdited,
|
||||||
isEdited = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
|
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemNoticeContent(
|
data class TimelineItemNoticeContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val pillifiedBody: CharSequence = body,
|
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val formattedBody: CharSequence,
|
||||||
override val formattedBody: CharSequence?,
|
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemNoticeContent"
|
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. */
|
/** The raw body of the event, in Markdown format. */
|
||||||
val body: String
|
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. */
|
/** The parsed HTML DOM of the formatted event body. */
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
|
|
||||||
/** The formatted body of the event, already parsed and with the DOM translated to Android spans. */
|
/** The formatted body of the event, already parsed and with the DOM translated to Android spans.
|
||||||
val formattedBody: CharSequence?
|
* 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. */
|
/** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
|
||||||
val plainText: String
|
val plainText: String
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemTextContent(
|
data class TimelineItemTextContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val pillifiedBody: CharSequence = body,
|
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val formattedBody: CharSequence,
|
||||||
override val formattedBody: CharSequence?,
|
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemTextContent"
|
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.Spannable
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import android.util.Patterns
|
||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.di.RoomScope
|
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.MatrixPatterns
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import io.element.android.libraries.matrix.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.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
|
import javax.inject.Inject
|
||||||
|
|
||||||
interface TextPillificationHelper {
|
interface TextPillificationHelper {
|
||||||
fun pillify(text: CharSequence): CharSequence
|
fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence
|
||||||
}
|
}
|
||||||
|
|
||||||
@ContributesBinding(RoomScope::class)
|
@ContributesBinding(RoomScope::class)
|
||||||
class DefaultTextPillificationHelper @Inject constructor(
|
class DefaultTextPillificationHelper @Inject constructor(
|
||||||
private val mentionSpanProvider: MentionSpanProvider,
|
private val mentionSpanProvider: MentionSpanProvider,
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
) : TextPillificationHelper {
|
) : TextPillificationHelper {
|
||||||
@Suppress("LoopWithTooManyJumpStatements")
|
@Suppress("LoopWithTooManyJumpStatements")
|
||||||
override fun pillify(text: CharSequence): CharSequence {
|
override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
|
||||||
val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
|
return SpannableStringBuilder(text).apply {
|
||||||
if (matches.isEmpty()) return text
|
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) {
|
for (match in matches) {
|
||||||
|
if (!text.canPillify(match.start, match.end)) continue
|
||||||
when (match.type) {
|
when (match.type) {
|
||||||
MatrixPatternType.USER_ID -> {
|
MatrixPatternType.USER_ID -> {
|
||||||
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
|
val userId = UserId(match.value)
|
||||||
if (!mentionSpanExists) {
|
val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
|
||||||
val userId = UserId(match.value)
|
text.replace(match.start, match.end, "@ ")
|
||||||
val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue
|
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
|
permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink ->
|
||||||
roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it }
|
// Also add a URLSpan in case of raw user id so it can be clicked
|
||||||
spannable.replace(match.start, match.end, "@ ")
|
val urlSpan = URLSpan(permalink)
|
||||||
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MatrixPatternType.ROOM_ALIAS -> {
|
MatrixPatternType.ROOM_ALIAS -> {
|
||||||
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
|
val roomAlias = RoomAlias(match.value)
|
||||||
if (!mentionSpanExists) {
|
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
|
||||||
val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue
|
text.replace(match.start, match.end, "@ ")
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
|
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
spannable.replace(match.start, match.end, "@ ")
|
permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink ->
|
||||||
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
// 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 -> {
|
MatrixPatternType.AT_ROOM -> {
|
||||||
val mentionSpanExists = spannable.getSpans<MentionSpan>(match.start, match.end).isNotEmpty()
|
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
|
||||||
if (!mentionSpanExists) {
|
text.replace(match.start, match.end, "@ ")
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "")
|
text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
spannable.replace(match.start, match.end, "@ ")
|
|
||||||
spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> Unit
|
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(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
isEditable = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -201,7 +201,7 @@ class ActionListPresenterTest {
|
||||||
isMine = false,
|
isMine = false,
|
||||||
isEditable = false,
|
isEditable = false,
|
||||||
isThreaded = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -248,7 +248,7 @@ class ActionListPresenterTest {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
isEditable = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -296,7 +296,7 @@ class ActionListPresenterTest {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
isEditable = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -344,7 +344,7 @@ class ActionListPresenterTest {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
isEditable = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -391,7 +391,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -439,7 +439,7 @@ class ActionListPresenterTest {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
isThreaded = 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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -486,7 +486,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -804,7 +804,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -852,7 +852,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -907,7 +907,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvents.ComputeForMessage(
|
ActionListEvents.ComputeForMessage(
|
||||||
|
|
@ -956,7 +956,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
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(
|
val redactedEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
|
|
@ -1006,7 +1006,7 @@ class ActionListPresenterTest {
|
||||||
eventId = null,
|
eventId = null,
|
||||||
isMine = true,
|
isMine = true,
|
||||||
canBeRepliedTo = false,
|
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(
|
initialState.eventSink.invoke(
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ internal fun aMessageEvent(
|
||||||
isMine: Boolean = true,
|
isMine: Boolean = true,
|
||||||
isEditable: Boolean = true,
|
isEditable: Boolean = true,
|
||||||
canBeRepliedTo: 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,
|
inReplyTo: InReplyToDetails? = null,
|
||||||
isThreaded: Boolean = false,
|
isThreaded: Boolean = false,
|
||||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
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.draft.FakeComposerDraftService
|
||||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
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.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.FakeTextPillificationHelper
|
||||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
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.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
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.matrix.ui.messages.reply.InReplyToDetails
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
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.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
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.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
|
|
@ -1535,8 +1536,11 @@ class MessageComposerPresenterTest {
|
||||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser),
|
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
|
||||||
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
|
permalinkParser = permalinkParser,
|
||||||
|
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||||
|
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||||
|
),
|
||||||
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||||
isRichTextEditorEnabled: Boolean = true,
|
isRichTextEditorEnabled: Boolean = true,
|
||||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||||
|
|
@ -1562,7 +1566,6 @@ class MessageComposerPresenterTest {
|
||||||
draftService = draftService,
|
draftService = draftService,
|
||||||
mentionSpanProvider = mentionSpanProvider,
|
mentionSpanProvider = mentionSpanProvider,
|
||||||
pillificationHelper = textPillificationHelper,
|
pillificationHelper = textPillificationHelper,
|
||||||
roomMemberProfilesCache = roomMemberProfilesCache,
|
|
||||||
suggestionsProcessor = SuggestionsProcessor(),
|
suggestionsProcessor = SuggestionsProcessor(),
|
||||||
).apply {
|
).apply {
|
||||||
isTesting = true
|
isTesting = true
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -23,10 +25,16 @@ import org.robolectric.RobolectricTestRunner
|
||||||
class DefaultHtmlConverterProviderTest {
|
class DefaultHtmlConverterProviderTest {
|
||||||
@get:Rule val composeTestRule = createComposeRule()
|
@get:Rule val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val provider = DefaultHtmlConverterProvider(
|
||||||
|
mentionSpanProvider = MentionSpanProvider(
|
||||||
|
permalinkParser = FakePermalinkParser(),
|
||||||
|
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||||
|
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `calling provide without calling Update first should throw an exception`() {
|
fun `calling provide without calling Update first should throw an exception`() {
|
||||||
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
|
|
||||||
|
|
||||||
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
||||||
|
|
||||||
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
||||||
|
|
@ -34,13 +42,11 @@ class DefaultHtmlConverterProviderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||||
val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||||
provider.Update(currentUserId = A_USER_ID)
|
provider.Update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
||||||
|
|
||||||
assertThat(htmlConverter).isNotNull()
|
assertThat(htmlConverter).isNotNull()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
|
import android.text.SpannedString
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
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 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.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
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
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
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
|
||||||
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.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.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.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 io.element.android.wysiwyg.view.spans.CustomMentionSpan
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -40,101 +47,100 @@ import org.junit.runner.RunWith
|
||||||
class TimelineTextViewTest {
|
class TimelineTextViewTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@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
|
@Test
|
||||||
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest {
|
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 charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||||
|
|
||||||
assertThat(result.getMentionSpans()).isEmpty()
|
assertThat(result.getMentionSpans()).isEmpty()
|
||||||
|
assert(formatLambda).isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest {
|
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 charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>")
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||||
|
|
||||||
assertThat(result.getMentionSpans()).isEmpty()
|
assertThat(result.getMentionSpans()).isEmpty()
|
||||||
|
assert(formatLambda).isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest {
|
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 charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
|
||||||
|
|
||||||
assertThat(result.getMentionSpans()).isEmpty()
|
assertThat(result.getMentionSpans()).isEmpty()
|
||||||
assertThat(result.toString()).isEqualTo(charSequence)
|
assertThat(result.toString()).isEqualTo(charSequence)
|
||||||
|
assert(formatLambda).isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 {
|
val charSequence = buildSpannedString {
|
||||||
append("Hello ")
|
append("Hello ")
|
||||||
inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) {
|
inSpans(MentionSpan(mentionType)) {
|
||||||
append(A_ROOM_ID.value)
|
append(A_ROOM_ID.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||||
|
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
val expectedDisplayText = mentionType.toString()
|
||||||
|
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
|
|
||||||
assertThat(result).isEqualTo(charSequence)
|
assertThat(result).isEqualTo(charSequence)
|
||||||
|
assert(formatLambda).isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
|
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
|
||||||
|
val mentionType = MentionType.User(userId = A_USER_ID)
|
||||||
val charSequence = buildSpannedString {
|
val charSequence = buildSpannedString {
|
||||||
append("Hello ")
|
append("Hello ")
|
||||||
inSpans(aMentionSpan(rawValue = A_USER_ID.value)) {
|
inSpans(MentionSpan(mentionType)) {
|
||||||
append("@NotAlice")
|
append("@NotAlice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||||
|
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
val expectedDisplayText = mentionType.toString()
|
||||||
|
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
|
assert(formatLambda).isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
|
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
|
||||||
|
val mentionType = MentionType.User(userId = A_USER_ID)
|
||||||
val charSequence = buildSpannedString {
|
val charSequence = buildSpannedString {
|
||||||
append("Hello ")
|
append("Hello ")
|
||||||
inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) {
|
inSpans(CustomMentionSpan(MentionSpan(mentionType))) {
|
||||||
append("@NotAlice")
|
append("@NotAlice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val mentionSpanUpdater = aMentionSpanUpdater()
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
val expectedDisplayText = mentionType.toString()
|
||||||
|
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
|
||||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
|
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
|
||||||
}
|
assert(formatLambda).isCalledOnce()
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `getTextWithResolvedMentions - replaces MentionSpan's text with user id if no display name is cached`() = runTest {
|
|
||||||
val charSequence = buildSpannedString {
|
|
||||||
append("Hello ")
|
|
||||||
inSpans(aMentionSpan(rawValue = A_USER_ID_2.value)) {
|
|
||||||
append("@NotAlice")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
|
|
||||||
|
|
||||||
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo(A_USER_ID_2.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
|
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
|
||||||
|
mentionSpanUpdater: MentionSpanUpdater,
|
||||||
content: TimelineItemTextBasedContent,
|
content: TimelineItemTextBasedContent,
|
||||||
): CharSequence {
|
): CharSequence {
|
||||||
val completable = CompletableDeferred<CharSequence>()
|
val completable = CompletableDeferred<CharSequence>()
|
||||||
setContent {
|
setContent {
|
||||||
val roomMemberProfilesCache = RoomMemberProfilesCache().apply {
|
|
||||||
replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME)))
|
|
||||||
}
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
|
LocalMentionSpanUpdater provides mentionSpanUpdater
|
||||||
) {
|
) {
|
||||||
completable.complete(getTextWithResolvedMentions(content = content))
|
completable.complete(getTextWithResolvedMentions(content = content))
|
||||||
}
|
}
|
||||||
|
|
@ -142,21 +148,20 @@ class TimelineTextViewTest {
|
||||||
return completable.await()
|
return completable.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aMentionSpan(
|
private fun aMentionSpanUpdater(): MentionSpanUpdater {
|
||||||
rawValue: String,
|
return DefaultMentionSpanUpdater(
|
||||||
text: String = "",
|
formatter = mentionSpanFormatter,
|
||||||
type: MentionSpan.Type = MentionSpan.Type.USER
|
theme = mentionSpanTheme,
|
||||||
) = MentionSpan(
|
roomMemberProfilesCache = RoomMemberProfilesCache(),
|
||||||
text = text,
|
roomNamesCache = RoomNamesCache(),
|
||||||
rawValue = rawValue,
|
)
|
||||||
type = type,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
|
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
formattedBody = formattedBody,
|
formattedBody = formattedBody ?: SpannedString(body),
|
||||||
isEdited = false
|
isEdited = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
formattedBody = null,
|
formattedBody = SpannedString("body"),
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -123,9 +122,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
formattedBody = null,
|
formattedBody = "body",
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -141,10 +139,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
formattedBody = null,
|
formattedBody = SpannedString("body"),
|
||||||
pillifiedBody = SpannableString("body"),
|
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +156,6 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
body = "https://www.example.org",
|
body = "https://www.example.org",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "https://www.example.org",
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
formattedBody = buildSpannedString {
|
formattedBody = buildSpannedString {
|
||||||
inSpans(URLSpan("https://www.example.org")) {
|
inSpans(URLSpan("https://www.example.org")) {
|
||||||
|
|
@ -223,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderDisambiguatedDisplayName = "Bob",
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
|
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -637,8 +632,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemNoticeContent(
|
val expected = TimelineItemNoticeContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
formattedBody = SpannedString("body"),
|
||||||
formattedBody = null,
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
|
|
@ -671,8 +665,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemEmoteContent(
|
val expected = TimelineItemEmoteContent(
|
||||||
body = "* Bob body",
|
body = "* Bob body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "* Bob body",
|
formattedBody = SpannedString("* Bob body"),
|
||||||
formattedBody = null,
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,21 @@ package io.element.android.features.messages.impl.utils
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.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.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
|
||||||
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.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 io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -30,90 +34,201 @@ class DefaultTextPillificationHelperTest {
|
||||||
@Test
|
@Test
|
||||||
fun `pillify - adds pills for user ids`() {
|
fun `pillify - adds pills for user ids`() {
|
||||||
val text = "A @user:server.com"
|
val text = "A @user:server.com"
|
||||||
|
val formatter = FakeMentionSpanFormatter()
|
||||||
|
val userId = UserId("@user:server.com")
|
||||||
val helper = aTextPillificationHelper(
|
val helper = aTextPillificationHelper(
|
||||||
permalinkparser = FakePermalinkParser(result = {
|
permalinkParser = FakePermalinkParser(result = {
|
||||||
PermalinkData.UserLink(UserId("@user:server.com"))
|
PermalinkData.UserLink(userId)
|
||||||
}),
|
}),
|
||||||
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
|
||||||
Result.success("https://matrix.to/#/@user:server.com")
|
Result.success("https://matrix.to/#/@user:server.com")
|
||||||
}),
|
}),
|
||||||
|
mentionSpanFormatter = formatter,
|
||||||
)
|
)
|
||||||
val pillified = helper.pillify(text)
|
val pillified = helper.pillify(text)
|
||||||
val mentionSpans = pillified.getMentionSpans()
|
val mentionSpans = pillified.getMentionSpans()
|
||||||
assertThat(mentionSpans).hasSize(1)
|
assertThat(mentionSpans).hasSize(1)
|
||||||
val mentionSpan = mentionSpans.firstOrNull()
|
val mentionSpan = mentionSpans.first()
|
||||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
|
assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
|
||||||
assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
|
val userType = mentionSpan.type as MentionType.User
|
||||||
assertThat(mentionSpan?.text).isEqualTo("@user:server.com")
|
assertThat(userType.userId).isEqualTo(userId)
|
||||||
}
|
val formatted = formatter.formatDisplayText(MentionType.User(userId))
|
||||||
|
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||||
@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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pillify - adds pills for room aliases`() {
|
fun `pillify - adds pills for room aliases`() {
|
||||||
val text = "A #room:server.com"
|
val text = "A #room:server.com"
|
||||||
|
val roomAlias = RoomAlias("#room:server.com")
|
||||||
|
val formatter = FakeMentionSpanFormatter()
|
||||||
val helper = aTextPillificationHelper(
|
val helper = aTextPillificationHelper(
|
||||||
permalinkparser = FakePermalinkParser(result = {
|
permalinkParser = FakePermalinkParser(result = {
|
||||||
PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com")))
|
PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias))
|
||||||
}),
|
}),
|
||||||
permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
|
permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
|
||||||
Result.success("https://matrix.to/#/#room:server.com")
|
Result.success("https://matrix.to/#/#room:server.com")
|
||||||
}),
|
}),
|
||||||
|
mentionSpanFormatter = formatter,
|
||||||
)
|
)
|
||||||
val pillified = helper.pillify(text)
|
val pillified = helper.pillify(text)
|
||||||
val mentionSpans = pillified.getMentionSpans()
|
val mentionSpans = pillified.getMentionSpans()
|
||||||
assertThat(mentionSpans).hasSize(1)
|
assertThat(mentionSpans).hasSize(1)
|
||||||
val mentionSpan = mentionSpans.firstOrNull()
|
val mentionSpan = mentionSpans.first()
|
||||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM)
|
assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java)
|
||||||
assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com")
|
val roomType = mentionSpan.type as MentionType.Room
|
||||||
assertThat(mentionSpan?.text).isEqualTo("#room:server.com")
|
assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias())
|
||||||
|
val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias()))
|
||||||
|
assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pillify - adds pills for @room mentions`() {
|
fun `pillify - adds pills for @room mentions`() {
|
||||||
val text = "An @room mention"
|
val text = "An @room mention"
|
||||||
val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = {
|
val formatter = FakeMentionSpanFormatter()
|
||||||
PermalinkData.FallbackLink(Uri.EMPTY)
|
val helper = aTextPillificationHelper(
|
||||||
}))
|
permalinkParser = FakePermalinkParser(result = {
|
||||||
|
PermalinkData.FallbackLink(Uri.EMPTY)
|
||||||
|
}),
|
||||||
|
mentionSpanFormatter = formatter,
|
||||||
|
)
|
||||||
val pillified = helper.pillify(text)
|
val pillified = helper.pillify(text)
|
||||||
val mentionSpans = pillified.getMentionSpans()
|
val mentionSpans = pillified.getMentionSpans()
|
||||||
assertThat(mentionSpans).hasSize(1)
|
assertThat(mentionSpans).hasSize(1)
|
||||||
val mentionSpan = mentionSpans.firstOrNull()
|
val mentionSpan = mentionSpans.first()
|
||||||
assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE)
|
assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone)
|
||||||
assertThat(mentionSpan?.rawValue).isEqualTo("@room")
|
val formatted = formatter.formatDisplayText(MentionType.Everyone)
|
||||||
assertThat(mentionSpan?.text).isEqualTo("@room")
|
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(
|
private fun aTextPillificationHelper(
|
||||||
permalinkparser: PermalinkParser = FakePermalinkParser(),
|
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||||
permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
|
||||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser),
|
mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||||
roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
|
): TextPillificationHelper {
|
||||||
) = DefaultTextPillificationHelper(
|
val mentionSpanProvider = MentionSpanProvider(
|
||||||
mentionSpanProvider = mentionSpanProvider,
|
permalinkParser = permalinkParser,
|
||||||
permalinkBuilder = permalinkBuilder,
|
mentionSpanFormatter = mentionSpanFormatter,
|
||||||
permalinkParser = permalinkparser,
|
mentionSpanTheme = MentionSpanTheme(A_USER_ID),
|
||||||
roomMemberProfilesCache = roomMemberProfilesCache,
|
)
|
||||||
)
|
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
|
package io.element.android.features.messages.impl.utils
|
||||||
|
|
||||||
class FakeTextPillificationHelper(
|
class FakeTextPillificationHelper(
|
||||||
private val pillifyLambda: (CharSequence) -> CharSequence = { it }
|
private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text }
|
||||||
) : TextPillificationHelper {
|
) : TextPillificationHelper {
|
||||||
override fun pillify(text: CharSequence): CharSequence {
|
override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
|
||||||
return pillifyLambda(text)
|
return pillifyLambda(text, pillifyPermalinks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,13 @@ package io.element.android.features.messages.test.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
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
|
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||||
|
|
||||||
class FakeHtmlConverterProvider(
|
class FakeHtmlConverterProvider(
|
||||||
private val transform: (String) -> CharSequence = { it },
|
private val transform: (String) -> CharSequence = { it },
|
||||||
) : HtmlConverterProvider {
|
) : HtmlConverterProvider {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Update(currentUserId: UserId) = Unit
|
override fun Update() = Unit
|
||||||
|
|
||||||
override fun provide(): HtmlConverter {
|
override fun provide(): HtmlConverter {
|
||||||
return object : HtmlConverter {
|
return object : HtmlConverter {
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,13 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakePermalinkParser(
|
class FakePermalinkParser(
|
||||||
private var result: () -> PermalinkData = { lambdaError() }
|
private var result: (String) -> PermalinkData = { lambdaError() }
|
||||||
) : PermalinkParser {
|
) : PermalinkParser {
|
||||||
fun givenResult(result: PermalinkData) {
|
fun givenResult(result: PermalinkData) {
|
||||||
this.result = { result }
|
this.result = { result }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parse(uriString: String): PermalinkData {
|
override fun parse(uriString: String): PermalinkData {
|
||||||
return result()
|
return result(uriString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,26 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.ui.messages
|
package io.element.android.libraries.matrix.ui.messages
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class RoomMemberProfilesCache @Inject constructor() {
|
class RoomMemberProfilesCache @Inject constructor() {
|
||||||
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
|
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
|
||||||
|
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
|
||||||
|
|
||||||
private val _lastCacheUpdate = MutableStateFlow(0L)
|
suspend fun replace(items: List<RoomMember>) = coroutineScope {
|
||||||
val lastCacheUpdate: StateFlow<Long> = _lastCacheUpdate
|
|
||||||
|
|
||||||
fun replace(items: List<RoomMember>) {
|
|
||||||
cache.value = items.associateBy { it.userId }
|
cache.value = items.associateBy { it.userId }
|
||||||
_lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDisplayName(userId: UserId): String? {
|
fun getDisplayName(userId: UserId): String? {
|
||||||
return cache.value[userId]?.disambiguatedDisplayName
|
return cache.value[userId]?.disambiguatedDisplayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalRoomMemberProfilesCache = staticCompositionLocalOf {
|
|
||||||
RoomMemberProfilesCache()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.ui.messages
|
||||||
|
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
class RoomNamesCache @Inject constructor() {
|
||||||
|
private val cache = MutableStateFlow(mapOf<RoomIdOrAlias, String?>())
|
||||||
|
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
|
||||||
|
|
||||||
|
suspend fun replace(items: List<RoomSummary>) = coroutineScope {
|
||||||
|
val roomNamesByRoomIdOrAlias = LinkedHashMap<RoomIdOrAlias, String?>(items.size * 2)
|
||||||
|
items
|
||||||
|
.forEach { summary ->
|
||||||
|
roomNamesByRoomIdOrAlias[summary.info.id.toRoomIdOrAlias()] = summary.info.name
|
||||||
|
val canonicalAlias = summary.info.canonicalAlias
|
||||||
|
if (canonicalAlias != null) {
|
||||||
|
roomNamesByRoomIdOrAlias[canonicalAlias.toRoomIdOrAlias()] = summary.info.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.value = roomNamesByRoomIdOrAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDisplayName(roomIdOrAlias: RoomIdOrAlias): String? {
|
||||||
|
return cache.value[roomIdOrAlias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,7 @@ fun TextComposer(
|
||||||
onReceiveSuggestion: (Suggestion?) -> Unit,
|
onReceiveSuggestion: (Suggestion?) -> Unit,
|
||||||
onSelectRichContent: ((Uri) -> Unit)?,
|
onSelectRichContent: ((Uri) -> Unit)?,
|
||||||
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
||||||
|
resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
subcomposing: Boolean = false,
|
subcomposing: Boolean = false,
|
||||||
|
|
@ -176,7 +177,7 @@ fun TextComposer(
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
resolveMentionDisplay = resolveMentionDisplay,
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
|
resolveRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||||
onError = onError,
|
onError = onError,
|
||||||
onTyping = onTyping,
|
onTyping = onTyping,
|
||||||
onSelectRichContent = onSelectRichContent,
|
onSelectRichContent = onSelectRichContent,
|
||||||
|
|
@ -930,6 +931,7 @@ private fun ATextComposer(
|
||||||
onTyping = {},
|
onTyping = {},
|
||||||
onReceiveSuggestion = {},
|
onReceiveSuggestion = {},
|
||||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||||
|
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||||
onSelectRichContent = null,
|
onSelectRichContent = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||||
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
|
|
||||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||||
|
|
@ -75,7 +74,7 @@ fun MarkdownTextInput(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mentionSpanTheme = LocalMentionSpanTheme.current
|
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -124,10 +123,9 @@ fun MarkdownTextInput(
|
||||||
},
|
},
|
||||||
update = { editText ->
|
update = { editText ->
|
||||||
editText.applyStyleInCompose(richTextEditorStyle)
|
editText.applyStyleInCompose(richTextEditorStyle)
|
||||||
|
val text = state.text.value()
|
||||||
|
mentionSpanUpdater.updateMentionSpans(text)
|
||||||
if (state.text.needsDisplaying()) {
|
if (state.text.needsDisplaying()) {
|
||||||
val text = state.text.value()
|
|
||||||
mentionSpanTheme.updateMentionStyles(text)
|
|
||||||
editText.updateEditableText(text)
|
editText.updateEditableText(text)
|
||||||
if (canUpdateState) {
|
if (canUpdateState) {
|
||||||
state.text.update(editText.editableText, false)
|
state.text.update(editText.editableText, false)
|
||||||
|
|
|
||||||
|
|
@ -11,119 +11,153 @@ import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.text.TextPaint
|
||||||
|
import android.text.TextUtils
|
||||||
import android.text.style.ReplacementSpan
|
import android.text.style.ReplacementSpan
|
||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import io.element.android.libraries.core.extensions.orEmpty
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
|
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A span that represents a mention (user, room, etc.) in text.
|
||||||
|
* @param type The type of mention this span represents.
|
||||||
|
*/
|
||||||
class MentionSpan(
|
class MentionSpan(
|
||||||
text: String,
|
val type: MentionType,
|
||||||
val rawValue: String,
|
|
||||||
val type: Type,
|
|
||||||
) : ReplacementSpan() {
|
) : ReplacementSpan() {
|
||||||
companion object {
|
private val backgroundPaint = Paint()
|
||||||
private const val MAX_LENGTH = 20
|
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
||||||
}
|
|
||||||
|
|
||||||
var backgroundColor: Int = 0
|
private var backgroundColor: Int = 0
|
||||||
var textColor: Int = 0
|
private var textColor: Int = 0
|
||||||
var startPadding: Int = 0
|
private var startPadding: Int = 0
|
||||||
var endPadding: Int = 0
|
private var endPadding: Int = 0
|
||||||
var typeface: Typeface = Typeface.DEFAULT
|
private var typeface: Typeface = Typeface.DEFAULT
|
||||||
|
|
||||||
private var textWidth = 0
|
private var measuredTextWidth = 0
|
||||||
private val backgroundPaint = Paint().apply {
|
|
||||||
isAntiAlias = true
|
|
||||||
color = backgroundColor
|
|
||||||
}
|
|
||||||
|
|
||||||
var text: String = text
|
// The formatted display text, will be set by the formatter
|
||||||
set(value) {
|
var displayText: CharSequence = ""
|
||||||
field = value
|
private set
|
||||||
mentionText = getActualText(text)
|
|
||||||
|
/**
|
||||||
|
* Updates the visual properties of this span.
|
||||||
|
*/
|
||||||
|
fun updateTheme(mentionSpanTheme: MentionSpanTheme) {
|
||||||
|
val isCurrentUser = when (type) {
|
||||||
|
is MentionType.User -> type.userId == mentionSpanTheme.currentUserId
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mentionText: CharSequence = getActualText(text)
|
|
||||||
|
|
||||||
fun update(mentionSpanTheme: MentionSpanTheme) {
|
|
||||||
val isCurrentUser = rawValue == mentionSpanTheme.currentUserId?.value
|
|
||||||
backgroundColor = when (type) {
|
backgroundColor = when (type) {
|
||||||
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
|
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
|
||||||
Type.ROOM -> mentionSpanTheme.otherBackgroundColor
|
is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor
|
||||||
Type.EVERYONE -> mentionSpanTheme.currentUserBackgroundColor
|
is MentionType.Room -> mentionSpanTheme.otherBackgroundColor
|
||||||
|
is MentionType.Message -> mentionSpanTheme.otherBackgroundColor
|
||||||
}
|
}
|
||||||
|
|
||||||
textColor = when (type) {
|
textColor = when (type) {
|
||||||
Type.USER -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
|
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
|
||||||
Type.ROOM -> mentionSpanTheme.otherTextColor
|
is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor
|
||||||
Type.EVERYONE -> mentionSpanTheme.currentUserTextColor
|
is MentionType.Room -> mentionSpanTheme.otherTextColor
|
||||||
|
is MentionType.Message -> mentionSpanTheme.otherTextColor
|
||||||
}
|
}
|
||||||
backgroundPaint.color = backgroundColor
|
|
||||||
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
|
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
|
||||||
startPadding = startPaddingPx
|
startPadding = startPaddingPx
|
||||||
endPadding = endPaddingPx
|
endPadding = endPaddingPx
|
||||||
typeface = mentionSpanTheme.typeface.value
|
typeface = mentionSpanTheme.typeface.value
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
/**
|
||||||
paint.typeface = typeface
|
* Updates the display text using a formatter.
|
||||||
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
|
*/
|
||||||
return textWidth + startPadding + endPadding
|
fun updateDisplayText(formatter: MentionSpanFormatter) {
|
||||||
|
displayText = formatter.formatDisplayText(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
override fun getSize(
|
||||||
|
paint: Paint,
|
||||||
|
text: CharSequence?,
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
fm: Paint.FontMetricsInt?
|
||||||
|
): Int {
|
||||||
|
textPaint.set(paint)
|
||||||
|
textPaint.typeface = typeface
|
||||||
|
// Measure the full text width without truncation
|
||||||
|
measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt()
|
||||||
|
return measuredTextWidth + startPadding + endPadding
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(
|
||||||
|
canvas: Canvas,
|
||||||
|
text: CharSequence?,
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
x: Float,
|
||||||
|
top: Int,
|
||||||
|
y: Int,
|
||||||
|
bottom: Int,
|
||||||
|
paint: Paint
|
||||||
|
) {
|
||||||
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
||||||
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
||||||
|
|
||||||
val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
|
val availableWidth = (canvas.width - x).coerceAtLeast(0f)
|
||||||
|
val measuredWidth = measuredTextWidth + startPadding + endPadding
|
||||||
|
val pillWidth = minOf(availableWidth, measuredWidth.toFloat())
|
||||||
|
|
||||||
|
backgroundPaint.color = backgroundColor
|
||||||
|
val rect = RectF(x, top.toFloat(), x + pillWidth, y.toFloat() + extraVerticalSpace)
|
||||||
val radius = rect.height() / 2
|
val radius = rect.height() / 2
|
||||||
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
||||||
paint.color = textColor
|
|
||||||
paint.typeface = typeface
|
|
||||||
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getActualText(text: String): CharSequence {
|
textPaint.set(paint)
|
||||||
return buildString {
|
textPaint.color = textColor
|
||||||
val mentionText = text.orEmpty()
|
textPaint.typeface = typeface
|
||||||
when (type) {
|
|
||||||
Type.USER -> {
|
val availableWidthForText = availableWidth - startPadding - endPadding
|
||||||
if (text.firstOrNull() != '@') {
|
val textToDraw = if (measuredTextWidth > availableWidthForText) {
|
||||||
append("@")
|
TextUtils.ellipsize(
|
||||||
}
|
displayText,
|
||||||
}
|
textPaint,
|
||||||
Type.ROOM -> {
|
availableWidthForText,
|
||||||
if (text.firstOrNull() != '#') {
|
TextUtils.TruncateAt.END
|
||||||
append("#")
|
)
|
||||||
}
|
} else {
|
||||||
}
|
displayText
|
||||||
Type.EVERYONE -> Unit
|
|
||||||
}
|
|
||||||
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
|
|
||||||
if (mentionText.length > MAX_LENGTH) {
|
|
||||||
append("…")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
|
||||||
|
|
||||||
enum class Type {
|
|
||||||
USER,
|
|
||||||
ROOM,
|
|
||||||
EVERYONE,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CharSequence.getMentionSpans(): List<MentionSpan> {
|
/**
|
||||||
|
* Sealed interface representing different types of mentions.
|
||||||
|
*/
|
||||||
|
sealed interface MentionType {
|
||||||
|
data class User(val userId: UserId) : MentionType
|
||||||
|
data class Room(val roomIdOrAlias: RoomIdOrAlias) : MentionType
|
||||||
|
data class Message(val roomIdOrAlias: RoomIdOrAlias, val eventId: EventId) : MentionType
|
||||||
|
data object Everyone : MentionType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to get all MentionSpans from a CharSequence.
|
||||||
|
*/
|
||||||
|
fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List<MentionSpan> {
|
||||||
return if (this is android.text.Spanned) {
|
return if (this is android.text.Spanned) {
|
||||||
val customMentionSpans = getSpans<CustomMentionSpan>()
|
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
|
||||||
if (customMentionSpans.isNotEmpty()) {
|
val customMentionSpans = getSpans<CustomMentionSpan>(start, end)
|
||||||
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
|
.map { it.providedSpan }
|
||||||
customMentionSpans.map { it.providedSpan }.filterIsInstance<MentionSpan>()
|
.filterIsInstance<MentionSpan>()
|
||||||
} else {
|
// Collect all direct mention spans
|
||||||
// Otherwise try to get the spans directly
|
val directMentionSpans = getSpans<MentionSpan>(start, end)
|
||||||
getSpans<MentionSpan>().toList()
|
// Return the union of both
|
||||||
}
|
customMentionSpans + directMentionSpans
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023, 2024 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.libraries.textcomposer.mentions
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val EVERYONE_DISPLAY_TEXT = "@room"
|
||||||
|
private const val BUBBLE_ICON = "\uD83D\uDCAC" // 💬
|
||||||
|
|
||||||
|
interface MentionSpanFormatter {
|
||||||
|
fun formatDisplayText(mentionType: MentionType): CharSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatter for MentionSpan display text.
|
||||||
|
* This class is responsible for formatting the display text of a MentionSpan
|
||||||
|
* based on its MentionType and context.
|
||||||
|
*/
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultMentionSpanFormatter @Inject constructor(
|
||||||
|
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||||
|
private val roomNamesCache: RoomNamesCache,
|
||||||
|
) : MentionSpanFormatter {
|
||||||
|
/**
|
||||||
|
* Format the display text for a mention span.
|
||||||
|
*
|
||||||
|
* @param mentionType The type of mention
|
||||||
|
* @return The formatted display text
|
||||||
|
*/
|
||||||
|
override fun formatDisplayText(mentionType: MentionType): CharSequence {
|
||||||
|
return when (mentionType) {
|
||||||
|
is MentionType.User -> formatUserMention(mentionType.userId)
|
||||||
|
is MentionType.Room -> formatRoomMention(mentionType.roomIdOrAlias)
|
||||||
|
is MentionType.Message -> formatMessageMention(mentionType.roomIdOrAlias)
|
||||||
|
is MentionType.Everyone -> EVERYONE_DISPLAY_TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatUserMention(userId: UserId): String {
|
||||||
|
// Try to get the display name from cache, fallback to userId
|
||||||
|
val displayName = roomMemberProfilesCache.getDisplayName(userId)
|
||||||
|
return if (displayName != null) {
|
||||||
|
"@$displayName"
|
||||||
|
} else {
|
||||||
|
userId.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatRoomMention(roomIdOrAlias: RoomIdOrAlias): String {
|
||||||
|
val displayName = roomNamesCache.getDisplayName(roomIdOrAlias)
|
||||||
|
return if (displayName != null) {
|
||||||
|
"#$displayName"
|
||||||
|
} else {
|
||||||
|
roomIdOrAlias.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMessageMention(
|
||||||
|
roomIdOrAlias: RoomIdOrAlias,
|
||||||
|
): String {
|
||||||
|
val roomMention = formatRoomMention(roomIdOrAlias)
|
||||||
|
return "$BUBBLE_ICON > $roomMention"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,46 +7,118 @@
|
||||||
|
|
||||||
package io.element.android.libraries.textcomposer.mentions
|
package io.element.android.libraries.textcomposer.mentions
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
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.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Stable
|
private const val EVERYONE_MENTION_TEXT = "@room"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for [MentionSpan]s.
|
||||||
|
*/
|
||||||
open class MentionSpanProvider @Inject constructor(
|
open class MentionSpanProvider @Inject constructor(
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
|
private val mentionSpanFormatter: MentionSpanFormatter,
|
||||||
|
private val mentionSpanTheme: MentionSpanTheme,
|
||||||
) {
|
) {
|
||||||
fun getMentionSpanFor(text: String, url: String): MentionSpan {
|
/**
|
||||||
|
* Creates a mention span from a text and URL.
|
||||||
|
*
|
||||||
|
* @param text The text associated with the mention
|
||||||
|
* @param url The URL associated with the mention
|
||||||
|
* @return A mention span if the URL can be parsed as a permalink, null otherwise
|
||||||
|
*/
|
||||||
|
fun getMentionSpanFor(text: String, url: String): MentionSpan? {
|
||||||
val permalinkData = permalinkParser.parse(url)
|
val permalinkData = permalinkParser.parse(url)
|
||||||
return when {
|
return getMentionSpanFor(text, permalinkData)
|
||||||
permalinkData is PermalinkData.UserLink -> {
|
}
|
||||||
MentionSpan(
|
|
||||||
text = text,
|
/**
|
||||||
rawValue = permalinkData.userId.toString(),
|
* Creates a mention span from a text and permalink data.
|
||||||
type = MentionSpan.Type.USER,
|
*
|
||||||
)
|
* @param text The text associated with the mention
|
||||||
|
* @param permalinkData The permalink data associated with the mention
|
||||||
|
* @return A mention span based on the permalink data, null if the permalink data is not supported
|
||||||
|
*/
|
||||||
|
private fun getMentionSpanFor(text: String, permalinkData: PermalinkData): MentionSpan? {
|
||||||
|
return when (permalinkData) {
|
||||||
|
is PermalinkData.UserLink -> {
|
||||||
|
createUserMentionSpan(permalinkData.userId)
|
||||||
}
|
}
|
||||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
is PermalinkData.RoomLink -> {
|
||||||
MentionSpan(
|
val eventId = permalinkData.eventId
|
||||||
text = text,
|
if (eventId != null) {
|
||||||
rawValue = "@room",
|
createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId)
|
||||||
type = MentionSpan.Type.EVERYONE,
|
} else {
|
||||||
)
|
createRoomMentionSpan(permalinkData.roomIdOrAlias)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
permalinkData is PermalinkData.RoomLink -> {
|
is PermalinkData.FallbackLink -> {
|
||||||
MentionSpan(
|
if (text == EVERYONE_MENTION_TEXT) {
|
||||||
text = text,
|
createEveryoneMentionSpan()
|
||||||
rawValue = permalinkData.roomIdOrAlias.identifier,
|
} else {
|
||||||
type = MentionSpan.Type.ROOM,
|
null
|
||||||
)
|
}
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
MentionSpan(
|
|
||||||
text = text,
|
|
||||||
rawValue = text,
|
|
||||||
type = MentionSpan.Type.ROOM,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mention span for a user mention.
|
||||||
|
*
|
||||||
|
* @param userId The user ID
|
||||||
|
* @return A mention span for the user
|
||||||
|
*/
|
||||||
|
fun createUserMentionSpan(userId: UserId): MentionSpan {
|
||||||
|
return MentionSpan(type = MentionType.User(userId = userId)).apply {
|
||||||
|
updateDisplayText(mentionSpanFormatter)
|
||||||
|
updateTheme(mentionSpanTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mention span for a room mention.
|
||||||
|
*
|
||||||
|
* @param roomIdOrAlias The room ID or alias
|
||||||
|
* @return A mention span for the room
|
||||||
|
*/
|
||||||
|
fun createRoomMentionSpan(roomIdOrAlias: RoomIdOrAlias): MentionSpan {
|
||||||
|
return MentionSpan(MentionType.Room(roomIdOrAlias)).apply {
|
||||||
|
updateDisplayText(mentionSpanFormatter)
|
||||||
|
updateTheme(mentionSpanTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mention span for a message mention.
|
||||||
|
*
|
||||||
|
* @param roomIdOrAlias The room ID or alias where the message is located
|
||||||
|
* @param eventId The event ID of the message
|
||||||
|
* @return A mention span for the message
|
||||||
|
*/
|
||||||
|
fun createMessageMentionSpan(
|
||||||
|
roomIdOrAlias: RoomIdOrAlias,
|
||||||
|
eventId: EventId,
|
||||||
|
): MentionSpan {
|
||||||
|
return MentionSpan(type = MentionType.Message(roomIdOrAlias, eventId)).apply {
|
||||||
|
updateTheme(mentionSpanTheme)
|
||||||
|
updateDisplayText(mentionSpanFormatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mention span for @room (everyone).
|
||||||
|
*
|
||||||
|
* @return A mention span for @room
|
||||||
|
*/
|
||||||
|
fun createEveryoneMentionSpan(): MentionSpan {
|
||||||
|
return MentionSpan(type = MentionType.Everyone).apply {
|
||||||
|
updateTheme(mentionSpanTheme)
|
||||||
|
updateDisplayText(mentionSpanFormatter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,9 @@ import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
|
@ -34,6 +32,9 @@ import io.element.android.libraries.designsystem.theme.currentUserMentionPillBac
|
||||||
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
|
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
|
||||||
import io.element.android.libraries.designsystem.theme.mentionPillBackground
|
import io.element.android.libraries.designsystem.theme.mentionPillBackground
|
||||||
import io.element.android.libraries.designsystem.theme.mentionPillText
|
import io.element.android.libraries.designsystem.theme.mentionPillText
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
|
|
@ -45,13 +46,14 @@ import javax.inject.Inject
|
||||||
/**
|
/**
|
||||||
* Theme used for mention spans.
|
* Theme used for mention spans.
|
||||||
* To make this work, you need to:
|
* To make this work, you need to:
|
||||||
* 1. Provide [LocalMentionSpanTheme] in a composable that wraps the ones where you want to use mentions.
|
* 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed.
|
||||||
* 2. Call [MentionSpanTheme.updateStyles] with the current [UserId] so the colors and sizes are computed.
|
* 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans.
|
||||||
* 3. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.update] to update the styles of the mention spans.
|
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
class MentionSpanTheme @Inject constructor() {
|
@SingleIn(SessionScope::class)
|
||||||
internal var currentUserId: UserId? = null
|
class MentionSpanTheme(val currentUserId: UserId) {
|
||||||
|
@Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
|
||||||
|
|
||||||
internal var currentUserTextColor: Int = 0
|
internal var currentUserTextColor: Int = 0
|
||||||
internal var currentUserBackgroundColor: Int = Color.WHITE
|
internal var currentUserBackgroundColor: Int = Color.WHITE
|
||||||
internal var otherTextColor: Int = 0
|
internal var otherTextColor: Int = 0
|
||||||
|
|
@ -66,8 +68,7 @@ class MentionSpanTheme @Inject constructor() {
|
||||||
*/
|
*/
|
||||||
@Suppress("ComposableNaming")
|
@Suppress("ComposableNaming")
|
||||||
@Composable
|
@Composable
|
||||||
fun updateStyles(currentUserId: UserId) {
|
fun updateStyles() {
|
||||||
this.currentUserId = currentUserId
|
|
||||||
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
|
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
|
||||||
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
|
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
|
||||||
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
|
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
|
||||||
|
|
@ -93,24 +94,28 @@ fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) {
|
||||||
val spanned = charSequence as? Spanned ?: return
|
val spanned = charSequence as? Spanned ?: return
|
||||||
val mentionSpans = spanned.getMentionSpans()
|
val mentionSpans = spanned.getMentionSpans()
|
||||||
for (span in mentionSpans) {
|
for (span in mentionSpans) {
|
||||||
span.update(this)
|
span.updateTheme(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@PreviewsDayNight
|
||||||
* Composition local containing the current [MentionSpanTheme].
|
@Composable
|
||||||
*/
|
internal fun MentionSpanThemePreview() {
|
||||||
val LocalMentionSpanTheme = staticCompositionLocalOf {
|
|
||||||
MentionSpanTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun MentionSpanThemePreview() {
|
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
val mentionSpanTheme = remember { MentionSpanTheme() }
|
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
|
||||||
val provider = remember {
|
val provider = remember {
|
||||||
MentionSpanProvider(
|
MentionSpanProvider(
|
||||||
|
mentionSpanTheme = mentionSpanTheme,
|
||||||
|
mentionSpanFormatter = object : MentionSpanFormatter {
|
||||||
|
override fun formatDisplayText(mentionType: MentionType): CharSequence {
|
||||||
|
return when (mentionType) {
|
||||||
|
is MentionType.User -> mentionType.userId.value
|
||||||
|
is MentionType.Room -> mentionType.roomIdOrAlias.identifier
|
||||||
|
is MentionType.Message -> "\uD83D\uDCAC️ > ${mentionType.roomIdOrAlias.identifier}"
|
||||||
|
is MentionType.Everyone -> "@room"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
permalinkParser = object : PermalinkParser {
|
permalinkParser = object : PermalinkParser {
|
||||||
override fun parse(uriString: String): PermalinkData {
|
override fun parse(uriString: String): PermalinkData {
|
||||||
return when (uriString) {
|
return when (uriString) {
|
||||||
|
|
@ -133,36 +138,31 @@ val LocalMentionSpanTheme = staticCompositionLocalOf {
|
||||||
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
|
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
|
||||||
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
|
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
|
||||||
fun mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org")
|
fun mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org")
|
||||||
fun mentionSpanEveryone() = provider.getMentionSpanFor("@room", "@room")
|
fun mentionSpanEveryone() = provider.createEveryoneMentionSpan()
|
||||||
mentionSpanTheme.updateStyles(currentUserId = UserId("@me:matrix.org"))
|
mentionSpanTheme.updateStyles()
|
||||||
|
|
||||||
CompositionLocalProvider(
|
AndroidView(factory = { context ->
|
||||||
LocalMentionSpanTheme provides mentionSpanTheme
|
TextView(context).apply {
|
||||||
) {
|
includeFontPadding = false
|
||||||
AndroidView(factory = { context ->
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
TextView(context).apply {
|
text = buildSpannedString {
|
||||||
includeFontPadding = false
|
append("This is a ")
|
||||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
append("@mention", mentionSpanMe(), 0)
|
||||||
text = buildSpannedString {
|
append(" to the current user and this is a ")
|
||||||
append("This is a ")
|
append("@mention", mentionSpanOther(), 0)
|
||||||
append("@mention", mentionSpanMe(), 0)
|
append(" to other user. This is for everyone in the ")
|
||||||
append(" to the current user and this is a ")
|
append("@room", mentionSpanEveryone(), 0)
|
||||||
append("@mention", mentionSpanOther(), 0)
|
append(". This one is for a link to another room: ")
|
||||||
append(" to other user. This is for everyone in the ")
|
append("#room:matrix.org", mentionSpanRoom(), 0)
|
||||||
append("@room", mentionSpanEveryone(), 0)
|
append("\n\n")
|
||||||
append(". This one is for a link to another room: ")
|
append("This ")
|
||||||
append("#room:matrix.org", mentionSpanRoom(), 0)
|
append("mention", mentionSpanMe(), 0)
|
||||||
append("\n\n")
|
append(" didn't have an '@' and it was automatically added, same as this ")
|
||||||
append("This ")
|
append("room:matrix.org", mentionSpanRoom(), 0)
|
||||||
append("mention", mentionSpanMe(), 0)
|
append(" one, which had no leading '#'.")
|
||||||
append(" didn't have an '@' and it was automatically added, same as this ")
|
|
||||||
append("room:matrix.org", mentionSpanRoom(), 0)
|
|
||||||
append(" one, which had no leading '#'.")
|
|
||||||
}
|
|
||||||
mentionSpanTheme.updateMentionStyles(text)
|
|
||||||
setTextColor(textColor)
|
|
||||||
}
|
}
|
||||||
})
|
setTextColor(textColor)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.textcomposer.mentions
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface MentionSpanUpdater {
|
||||||
|
fun updateMentionSpans(text: CharSequence): CharSequence
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMentionSpans(text: CharSequence): CharSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultMentionSpanUpdater @Inject constructor(
|
||||||
|
private val formatter: MentionSpanFormatter,
|
||||||
|
private val theme: MentionSpanTheme,
|
||||||
|
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||||
|
private val roomNamesCache: RoomNamesCache,
|
||||||
|
) : MentionSpanUpdater {
|
||||||
|
@Composable
|
||||||
|
override fun rememberMentionSpans(text: CharSequence): CharSequence {
|
||||||
|
val isLightTheme = ElementTheme.isLightTheme
|
||||||
|
val roomInfoCacheUpdate by roomNamesCache.updateFlow.collectAsState(0)
|
||||||
|
val roomMemberProfilesCacheUpdate by roomMemberProfilesCache.updateFlow.collectAsState(0)
|
||||||
|
return remember(text, roomInfoCacheUpdate, roomMemberProfilesCacheUpdate, isLightTheme) {
|
||||||
|
updateMentionSpans(text)
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateMentionSpans(text: CharSequence): CharSequence {
|
||||||
|
for (mentionSpan in text.getMentionSpans()) {
|
||||||
|
mentionSpan.updateTheme(theme)
|
||||||
|
mentionSpan.updateDisplayText(formatter)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoOpMentionSpanUpdater : MentionSpanUpdater {
|
||||||
|
override fun updateMentionSpans(text: CharSequence): CharSequence {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun rememberMentionSpans(text: CharSequence): CharSequence {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalMentionSpanUpdater = staticCompositionLocalOf<MentionSpanUpdater> { NoOpMentionSpanUpdater }
|
||||||
|
|
@ -21,14 +21,13 @@ import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.SaverScope
|
import androidx.compose.runtime.saveable.SaverScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.text.getSpans
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
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.PermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
|
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -48,39 +47,33 @@ class MarkdownTextEditorState(
|
||||||
fun insertSuggestion(
|
fun insertSuggestion(
|
||||||
resolvedSuggestion: ResolvedSuggestion,
|
resolvedSuggestion: ResolvedSuggestion,
|
||||||
mentionSpanProvider: MentionSpanProvider,
|
mentionSpanProvider: MentionSpanProvider,
|
||||||
permalinkBuilder: PermalinkBuilder,
|
|
||||||
) {
|
) {
|
||||||
val suggestion = currentSuggestion ?: return
|
val suggestion = currentSuggestion ?: return
|
||||||
when (resolvedSuggestion) {
|
when (resolvedSuggestion) {
|
||||||
is ResolvedSuggestion.AtRoom -> {
|
is ResolvedSuggestion.AtRoom -> {
|
||||||
val currentText = SpannableStringBuilder(text.value())
|
val currentText = SpannableStringBuilder(text.value())
|
||||||
val replaceText = "@room"
|
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
|
||||||
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
|
|
||||||
currentText.replace(suggestion.start, suggestion.end, "@ ")
|
currentText.replace(suggestion.start, suggestion.end, "@ ")
|
||||||
val end = suggestion.start + 1
|
val end = suggestion.start + 1
|
||||||
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
text.update(currentText, true)
|
text.update(currentText, true)
|
||||||
selection = IntRange(end + 1, end + 1)
|
selection = IntRange(end + 1, end + 1)
|
||||||
}
|
}
|
||||||
is ResolvedSuggestion.Member -> {
|
is ResolvedSuggestion.Member -> {
|
||||||
val currentText = SpannableStringBuilder(text.value())
|
val currentText = SpannableStringBuilder(text.value())
|
||||||
val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value
|
val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
|
||||||
val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return
|
|
||||||
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
|
|
||||||
currentText.replace(suggestion.start, suggestion.end, "@ ")
|
currentText.replace(suggestion.start, suggestion.end, "@ ")
|
||||||
val end = suggestion.start + 1
|
val end = suggestion.start + 1
|
||||||
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
this.text.update(currentText, true)
|
this.text.update(currentText, true)
|
||||||
this.selection = IntRange(end + 1, end + 1)
|
this.selection = IntRange(end + 1, end + 1)
|
||||||
}
|
}
|
||||||
is ResolvedSuggestion.Alias -> {
|
is ResolvedSuggestion.Alias -> {
|
||||||
val currentText = SpannableStringBuilder(text.value())
|
val currentText = SpannableStringBuilder(text.value())
|
||||||
val text = resolvedSuggestion.roomAlias.value
|
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
|
||||||
val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return
|
|
||||||
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
|
|
||||||
currentText.replace(suggestion.start, suggestion.end, "# ")
|
currentText.replace(suggestion.start, suggestion.end, "# ")
|
||||||
val end = suggestion.start + 1
|
val end = suggestion.start + 1
|
||||||
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
this.text.update(currentText, true)
|
this.text.update(currentText, true)
|
||||||
this.selection = IntRange(end + 1, end + 1)
|
this.selection = IntRange(end + 1, end + 1)
|
||||||
}
|
}
|
||||||
|
|
@ -98,19 +91,23 @@ class MarkdownTextEditorState(
|
||||||
val start = charSequence.getSpanStart(mention)
|
val start = charSequence.getSpanStart(mention)
|
||||||
val end = charSequence.getSpanEnd(mention)
|
val end = charSequence.getSpanEnd(mention)
|
||||||
when (mention.type) {
|
when (mention.type) {
|
||||||
MentionSpan.Type.USER -> {
|
is MentionType.User -> {
|
||||||
permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link ->
|
permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link ->
|
||||||
replace(start, end, "[${mention.rawValue}]($link)")
|
replace(start, end, "[${mention.type.userId}]($link)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MentionSpan.Type.EVERYONE -> {
|
is MentionType.Everyone -> {
|
||||||
replace(start, end, "@room")
|
replace(start, end, "@room")
|
||||||
}
|
}
|
||||||
MentionSpan.Type.ROOM -> {
|
is MentionType.Room -> {
|
||||||
permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link ->
|
val roomIdOrAlias = mention.type.roomIdOrAlias
|
||||||
replace(start, end, "[${mention.text}]($link)")
|
if (roomIdOrAlias is RoomIdOrAlias.Alias) {
|
||||||
|
permalinkBuilder.permalinkForRoomAlias(roomIdOrAlias.roomAlias).getOrNull()?.let { link ->
|
||||||
|
replace(start, end, "[${roomIdOrAlias.roomAlias}]($link)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,12 +119,12 @@ class MarkdownTextEditorState(
|
||||||
|
|
||||||
fun getMentions(): List<IntentionalMention> {
|
fun getMentions(): List<IntentionalMention> {
|
||||||
val text = SpannableString(text.value())
|
val text = SpannableString(text.value())
|
||||||
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
|
val mentionSpans = text.getMentionSpans()
|
||||||
return mentionSpans.mapNotNull { mentionSpan ->
|
return mentionSpans.mapNotNull { mentionSpan ->
|
||||||
when (mentionSpan.type) {
|
when (mentionSpan.type) {
|
||||||
MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue))
|
is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId)
|
||||||
MentionSpan.Type.EVERYONE -> IntentionalMention.Room
|
is MentionType.Everyone -> IntentionalMention.Room
|
||||||
MentionSpan.Type.ROOM -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
|
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
|
||||||
|
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
|
|
@ -146,7 +145,6 @@ class MarkdownTextInputTest {
|
||||||
@Test
|
@Test
|
||||||
fun `inserting a mention replaces the existing text with a span`() = runTest {
|
fun `inserting a mention replaces the existing text with a span`() = runTest {
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
|
||||||
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
|
|
||||||
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
|
||||||
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
|
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
|
||||||
rule.setMarkdownTextInput(state = state)
|
rule.setMarkdownTextInput(state = state)
|
||||||
|
|
@ -155,8 +153,7 @@ class MarkdownTextInputTest {
|
||||||
editor = it.findEditor()
|
editor = it.findEditor()
|
||||||
state.insertSuggestion(
|
state.insertSuggestion(
|
||||||
ResolvedSuggestion.Member(roomMember = aRoomMember()),
|
ResolvedSuggestion.Member(roomMember = aRoomMember()),
|
||||||
MentionSpanProvider(permalinkParser = permalinkParser),
|
aMentionSpanProvider(permalinkParser),
|
||||||
permalinkBuilder,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rule.awaitIdle()
|
rule.awaitIdle()
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -28,22 +27,22 @@ class IntentionalMentionSpanProviderTest {
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
private val permalinkParser = FakePermalinkParser()
|
private val permalinkParser = FakePermalinkParser()
|
||||||
private val mentionSpanProvider = MentionSpanProvider(
|
private val mentionSpanProvider = aMentionSpanProvider(permalinkParser)
|
||||||
permalinkParser = permalinkParser,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getting mention span for a user returns a MentionSpan of type USER`() {
|
fun `getting mention span for a user returns a MentionSpan of type USER`() {
|
||||||
permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
|
permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}")
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}")
|
||||||
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.USER)
|
assertThat(mentionSpan?.type).isInstanceOf(MentionType.User::class.java)
|
||||||
|
val userType = mentionSpan?.type as MentionType.User
|
||||||
|
assertThat(userType.userId).isEqualTo(A_USER_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
|
fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
|
||||||
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
|
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
|
||||||
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.EVERYONE)
|
assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -54,6 +53,8 @@ class IntentionalMentionSpanProviderTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
|
||||||
assertThat(mentionSpan.type).isEqualTo(MentionSpan.Type.ROOM)
|
assertThat(mentionSpan?.type).isInstanceOf(MentionType.Room::class.java)
|
||||||
|
val roomType = mentionSpan?.type as MentionType.Room
|
||||||
|
assertThat(roomType.roomIdOrAlias).isEqualTo(RoomAlias("#room:matrix.org").toRoomIdOrAlias())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.textcomposer.impl.mentions
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||||
|
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.DefaultMentionSpanFormatter
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MentionSpanFormatterTest {
|
||||||
|
private val roomMemberProfilesCache = RoomMemberProfilesCache()
|
||||||
|
private val roomNamesCache = RoomNamesCache()
|
||||||
|
private val formatter = DefaultMentionSpanFormatter(
|
||||||
|
roomMemberProfilesCache = roomMemberProfilesCache,
|
||||||
|
roomNamesCache = roomNamesCache,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats user mention with empty cache`() = runTest {
|
||||||
|
val userId = A_USER_ID
|
||||||
|
val mentionType = MentionType.User(userId)
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
assertThat(result.toString()).isEqualTo(userId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats user mention with filled cache`() = runTest {
|
||||||
|
val userId = A_USER_ID
|
||||||
|
val roomMember = aRoomMember(userId, displayName = "alice")
|
||||||
|
roomMemberProfilesCache.replace(listOf(roomMember))
|
||||||
|
val mentionType = MentionType.User(userId)
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
assertThat(result.toString()).isEqualTo("@alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats room mention with empty cache`() = runTest {
|
||||||
|
val roomAlias = A_ROOM_ALIAS
|
||||||
|
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo(roomAlias.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats room mention with filled cache`() = runTest {
|
||||||
|
val roomAlias = A_ROOM_ALIAS
|
||||||
|
val roomSummary = aRoomSummary(
|
||||||
|
canonicalAlias = roomAlias,
|
||||||
|
name = "my room"
|
||||||
|
)
|
||||||
|
roomNamesCache.replace(listOf(roomSummary))
|
||||||
|
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo("#my room")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats room mention with room id and empty cache`() = runTest {
|
||||||
|
val roomId = A_ROOM_ID
|
||||||
|
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo(roomId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats room mention with room id and filled cache`() = runTest {
|
||||||
|
val roomId = A_ROOM_ID
|
||||||
|
val roomSummary = aRoomSummary(
|
||||||
|
roomId = roomId,
|
||||||
|
name = "my room"
|
||||||
|
)
|
||||||
|
roomNamesCache.replace(listOf(roomSummary))
|
||||||
|
|
||||||
|
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo("#my room")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats message mention with empty cache`() = runTest {
|
||||||
|
val roomId = A_ROOM_ID
|
||||||
|
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo("💬 > ${roomId.value}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats message mention with filled cache`() = runTest {
|
||||||
|
val roomId = A_ROOM_ID
|
||||||
|
val roomSummary = aRoomSummary(
|
||||||
|
roomId = roomId,
|
||||||
|
name = "my room"
|
||||||
|
)
|
||||||
|
roomNamesCache.replace(listOf(roomSummary))
|
||||||
|
|
||||||
|
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo("💬 > #my room")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDisplayText - formats everyone mention`() = runTest {
|
||||||
|
val mentionType = MentionType.Everyone
|
||||||
|
|
||||||
|
val result = formatter.formatDisplayText(mentionType)
|
||||||
|
|
||||||
|
assertThat(result.toString()).isEqualTo("@room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.textcomposer.impl.mentions
|
||||||
|
|
||||||
|
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.FakePermalinkParser
|
||||||
|
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
|
||||||
|
|
||||||
|
fun aMentionSpanProvider(
|
||||||
|
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||||
|
mentionSpanFormatter: MentionSpanFormatter = object : MentionSpanFormatter {
|
||||||
|
override fun formatDisplayText(mentionType: MentionType): CharSequence {
|
||||||
|
return mentionType.toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mentionSpanTheme: MentionSpanTheme = MentionSpanTheme(A_USER_ID),
|
||||||
|
): MentionSpanProvider {
|
||||||
|
return MentionSpanProvider(
|
||||||
|
permalinkParser = permalinkParser,
|
||||||
|
mentionSpanFormatter = mentionSpanFormatter,
|
||||||
|
mentionSpanTheme = mentionSpanTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
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.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
|
|
@ -20,8 +22,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
|
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||||
|
|
@ -35,9 +38,8 @@ class MarkdownTextEditorStateTest {
|
||||||
fun `insertMention - room alias - getMentions return empty list`() {
|
fun `insertMention - room alias - getMentions return empty list`() {
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||||
val suggestion = aRoomAliasSuggestion()
|
val suggestion = aRoomAliasSuggestion()
|
||||||
val permalinkBuilder = FakePermalinkBuilder()
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider()
|
val mentionSpanProvider = aMentionSpanProvider()
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
|
state.insertSuggestion(suggestion, mentionSpanProvider)
|
||||||
assertThat(state.getMentions()).isEmpty()
|
assertThat(state.getMentions()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,9 +50,8 @@ class MarkdownTextEditorStateTest {
|
||||||
}
|
}
|
||||||
val suggestion = aRoomAliasSuggestion()
|
val suggestion = aRoomAliasSuggestion()
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
||||||
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) })
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
|
state.insertSuggestion(suggestion, mentionSpanProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -60,9 +61,8 @@ class MarkdownTextEditorStateTest {
|
||||||
}
|
}
|
||||||
val suggestion = aRoomAliasSuggestion()
|
val suggestion = aRoomAliasSuggestion()
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
||||||
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") })
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
|
state.insertSuggestion(suggestion, mentionSpanProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -70,31 +70,11 @@ class MarkdownTextEditorStateTest {
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||||
val member = aRoomMember()
|
val member = aRoomMember()
|
||||||
val mention = ResolvedSuggestion.Member(member)
|
val mention = ResolvedSuggestion.Member(member)
|
||||||
val permalinkBuilder = FakePermalinkBuilder()
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider()
|
val mentionSpanProvider = aMentionSpanProvider()
|
||||||
|
state.insertSuggestion(mention, mentionSpanProvider)
|
||||||
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
|
|
||||||
|
|
||||||
assertThat(state.getMentions()).isEmpty()
|
assertThat(state.getMentions()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `insertSuggestion - with member but failed PermalinkBuilder result`() {
|
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
|
||||||
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
|
||||||
}
|
|
||||||
val member = aRoomMember()
|
|
||||||
val mention = ResolvedSuggestion.Member(member)
|
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
|
|
||||||
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) })
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
|
||||||
|
|
||||||
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
|
|
||||||
|
|
||||||
val mentions = state.getMentions()
|
|
||||||
assertThat(mentions).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `insertSuggestion - with member`() {
|
fun `insertSuggestion - with member`() {
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
||||||
|
|
@ -103,10 +83,9 @@ class MarkdownTextEditorStateTest {
|
||||||
val member = aRoomMember()
|
val member = aRoomMember()
|
||||||
val mention = ResolvedSuggestion.Member(member)
|
val mention = ResolvedSuggestion.Member(member)
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
|
||||||
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") })
|
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
|
|
||||||
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
|
state.insertSuggestion(mention, mentionSpanProvider)
|
||||||
|
|
||||||
val mentions = state.getMentions()
|
val mentions = state.getMentions()
|
||||||
assertThat(mentions).isNotEmpty()
|
assertThat(mentions).isNotEmpty()
|
||||||
|
|
@ -119,11 +98,10 @@ class MarkdownTextEditorStateTest {
|
||||||
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
||||||
}
|
}
|
||||||
val mention = ResolvedSuggestion.AtRoom
|
val mention = ResolvedSuggestion.AtRoom
|
||||||
val permalinkBuilder = FakePermalinkBuilder()
|
|
||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
|
|
||||||
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
|
state.insertSuggestion(mention, mentionSpanProvider)
|
||||||
|
|
||||||
val mentions = state.getMentions()
|
val mentions = state.getMentions()
|
||||||
assertThat(mentions).isNotEmpty()
|
assertThat(mentions).isNotEmpty()
|
||||||
|
|
@ -177,16 +155,10 @@ class MarkdownTextEditorStateTest {
|
||||||
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aMentionSpanProvider(
|
|
||||||
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
|
|
||||||
): MentionSpanProvider {
|
|
||||||
return MentionSpanProvider(permalinkParser)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun aMarkdownTextWithMentions(): CharSequence {
|
private fun aMarkdownTextWithMentions(): CharSequence {
|
||||||
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER)
|
val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org")))
|
||||||
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE)
|
val atRoomMentionSpan = MentionSpan(MentionType.Everyone)
|
||||||
val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM)
|
val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias()))
|
||||||
return buildSpannedString {
|
return buildSpannedString {
|
||||||
append("Hello ")
|
append("Hello ")
|
||||||
inSpans(userMentionSpan) {
|
inSpans(userMentionSpan) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f541f0a05b49272ef3e379a9a629c71458b37435928338a3a58ac852b38eb803
|
oid sha256:73a225eeb9c217ada941b5add96dd2f832fb33b702f86eae5e1f5ecbee5a4bb2
|
||||||
size 48802
|
size 51625
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a542b3be557dddc3aba16bda8bb7db1bf0078e6bb3abcc42a11c723e8dba3504
|
oid sha256:ab365d585b76ab0723c9998cc13f755640b7f086b99b55804a38e103b8d42416
|
||||||
size 46683
|
size 49440
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ Compose:
|
||||||
- LocalMediaItemPresenterFactories
|
- LocalMediaItemPresenterFactories
|
||||||
- LocalTimelineItemPresenterFactories
|
- LocalTimelineItemPresenterFactories
|
||||||
- LocalRoomMemberProfilesCache
|
- LocalRoomMemberProfilesCache
|
||||||
- LocalMentionSpanTheme
|
- LocalMentionSpanUpdater
|
||||||
- LocalAnalyticsService
|
- LocalAnalyticsService
|
||||||
- LocalBuildMeta
|
- LocalBuildMeta
|
||||||
CompositionLocalNaming:
|
CompositionLocalNaming:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue