Integrate the new timeline renderer behind a feature flag (#7722)

* Integrate the new renderer with the timeline UI

* Composable functions are now in `:libraries:htmlrenderer:api` so we don't need to add `impl` to other modules.

* Improve inline code span

* Return both the text and URL in `onLinkClick` and `onLinkLongClick` so we can check the URL is not misleading

* Remove `SelectionContainer` for now - it creates issues when long pressing

* Make `LocalComposeTimelineEventRenderer` contain a `ComposeLocalTimelineEventRendererData` instance. This checks if the new rendering is enabled and what is the current user id so it can be used for styling mention pills

* Add fading edges to code block to indicate scrolling is possible

* Long pressing in a code block copies its contents to the clipboard

* Fix alignment of ordered list item prefixes

* Don't use default values in State data classes

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-09-22 10:26:35 +02:00
committed by GitHub
parent 0ea76ecebf
commit 4e47c6170e
61 changed files with 524 additions and 170 deletions
+2
View File
@@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.featureflag.api)
@@ -111,4 +112,5 @@ dependencies {
testImplementation(projects.libraries.emoji.impl)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.slashcommands.test)
testImplementation(projects.libraries.htmlrenderer.test)
}
@@ -107,6 +107,7 @@ class PinnedMessagesListPresenter(
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
currentUserId = room.sessionId,
isDm = roomInfo.isDm,
name = roomInfo.name,
// We don't need to compute those values
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Element Creations 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.timeline
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.matrix.api.core.UserId
/**
* Local configuration for the timeline event renderer, so it can be accessed from UI components without having to pass it down through parameters.
*/
val LocalTimelineEventRendererConfig = staticCompositionLocalOf {
ComposeLocalTimelineEventRendererConfig(
isComposeRendererEnabled = false,
currentUserId = null,
)
}
/**
* Configuration for the timeline event renderer.
*/
data class ComposeLocalTimelineEventRendererConfig(
/** Whether the compose renderer is enabled or not. If `false` (the default option), the view-based renderer will be used instead. */
val isComposeRendererEnabled: Boolean,
/** The current user ID, if known. This can be used to display UI sent by/owned by the current user differently. */
val currentUserId: UserId?,
)
@@ -163,6 +163,9 @@ class TimelinePresenter(
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
val useNewTimelineEventRenderer by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
}
val timelineProtectionState = timelineProtectionPresenter.present()
@@ -434,6 +437,7 @@ class TimelinePresenter(
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf {
TimelineRoomInfo(
currentUserId = room.sessionId,
name = roomInfo.name,
isDm = roomInfo.isDm,
userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
@@ -463,6 +467,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = ::handleEvent,
)
}
@@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList
@@ -37,6 +38,8 @@ data class TimelineState(
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
// When true, formatted message bodies are rendered with the native Compose renderer.
val useNewTimelineEventRenderer: Boolean,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -76,6 +79,7 @@ sealed interface FocusRequestState {
}
data class TimelineRoomInfo(
val currentUserId: UserId,
val isDm: Boolean,
val name: String?,
val userHasPermissionToSendMessage: Boolean,
@@ -63,6 +63,7 @@ fun aTimelineState(
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
useNewTimelineEventRenderer: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -84,6 +85,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = eventSink,
)
}
@@ -277,6 +279,7 @@ internal fun aRedactedMessagesGroupedEvents(
}
internal fun aTimelineRoomInfo(
currentUserId: UserId = UserId("@user:domain"),
name: String = ROOM_NAME,
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
@@ -284,6 +287,7 @@ internal fun aTimelineRoomInfo(
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
predecessorRoom: PredecessorRoom? = null,
) = TimelineRoomInfo(
currentUserId = currentUserId,
isDm = isDm,
name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
@@ -98,7 +98,6 @@ import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAto
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
@@ -191,80 +190,88 @@ fun TimelineView(
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = true,
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
) {
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.identifier() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
ComposeLocalTimelineEventRendererConfig(
isComposeRendererEnabled = state.useNewTimelineEventRenderer,
currentUserId = state.timelineRoomInfo.currentUserId,
)
}
CompositionLocalProvider(LocalTimelineEventRendererConfig provides composeLocalTimelineEventRendererConfig) {
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = true,
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
) {
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.identifier() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
}
}
FocusRequestStateView(
focusRequestState = state.focusRequestState,
onClearFocusRequestState = ::clearFocusRequestState
)
TimelinePrefetchingHelper(
lazyListState = lazyListState,
prefetch = ::prefetchMoreItems
)
TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
newEventState = state.newEventState,
isLive = state.isLive,
focusRequestState = state.focusRequestState,
displayJumpToUnread = state.displayJumpToUnread,
jumpToUnread = state.jumpToUnread,
onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender,
onMarkAllAsRead = ::onMarkAllAsRead,
onFocusOnEvent = ::onFocusOnEvent,
)
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
FocusRequestStateView(
focusRequestState = state.focusRequestState,
onClearFocusRequestState = ::clearFocusRequestState
)
TimelinePrefetchingHelper(
lazyListState = lazyListState,
prefetch = ::prefetchMoreItems
)
TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
newEventState = state.newEventState,
isLive = state.isLive,
focusRequestState = state.focusRequestState,
displayJumpToUnread = state.displayJumpToUnread,
jumpToUnread = state.jumpToUnread,
onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender,
onMarkAllAsRead = ::onMarkAllAsRead,
onFocusOnEvent = ::onFocusOnEvent,
)
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.LocalTimelineEventRendererConfig
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
@@ -32,6 +33,7 @@ import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.htmlrenderer.api.ui.HtmlMessageContent
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
@@ -50,6 +52,31 @@ fun TimelineItemTextView(
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
if (LocalUiTestMode.current) return
// When the new timeline event renderer is enabled and the message has a parsed tree, render it
// natively with Compose. Otherwise fall back to the legacy rich text editor view below.
val messageTree = content.messageTree
if (LocalTimelineEventRendererConfig.current.isComposeRendererEnabled && messageTree != null) {
Box(modifier.semantics { contentDescription = content.plainText }) {
HtmlMessageContent(
node = messageTree,
currentUserId = LocalTimelineEventRendererConfig.current.currentUserId,
onLinkClick = { url, text -> onLinkClick(Link(url = url, text = text)) },
onLinkLongClick = { url, text -> onLinkLongClick(Link(url = url, text = text)) },
onContentLayoutChange = { data ->
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = data.contentWidth,
contentHeight = data.contentHeight,
nonOverlappingContentWidth = data.nonOverlappingContentWidth,
nonOverlappingContentHeight = data.nonOverlappingContentHeight,
)
)
},
)
}
return
}
val isInPreview = LocalInspectionMode.current
val emojiOnly = remember(content.body, content.formattedBody, isInPreview) {
content.formattedBody.toString() == content.body &&
@@ -33,6 +33,9 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -71,8 +74,10 @@ class TimelineItemContentMessageFactory(
private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper,
private val htmlMessageParser: HtmlMessageParser,
private val featureFlagService: FeatureFlagService,
) {
fun create(
suspend fun create(
content: MessageContent,
senderId: UserId,
senderProfile: ProfileDetails,
@@ -93,6 +98,28 @@ class TimelineItemContentMessageFactory(
htmlDocument = dom,
formattedBody = formattedBody,
isEdited = content.isEdited,
messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) {
dom?.let(htmlMessageParser::parse)
} else {
null
},
)
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = htmlDocument?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
TimelineItemTextContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) {
htmlDocument?.let(htmlMessageParser::parse)
} else {
null
},
)
}
is ImageMessageType -> {
@@ -251,28 +278,15 @@ class TimelineItemContentMessageFactory(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = htmlDocument?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
TimelineItemNoticeContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent(
body = body,
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
messageTree = htmlDocument?.let(htmlMessageParser::parse),
)
}
is GalleryMessageType -> {
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemEmoteContent(
override val htmlDocument: Document?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemNoticeContent(
override val htmlDocument: Document?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import org.jsoup.nodes.Document
/**
@@ -24,6 +25,12 @@ sealed interface TimelineItemTextBasedContent :
/** The parsed HTML DOM of the formatted event body. */
val htmlDocument: Document?
/**
* The formatted body parsed into a tree of [DocumentNode] for the native Compose renderer.
* Null when there is no formatted body. Only used when the new timeline event renderer is enabled.
*/
val messageTree: DocumentNode?
/** The formatted body of the event, already parsed and with the DOM translated to Android spans.
* This can also includes mention spans from permalink parsing */
val formattedBody: CharSequence
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemTextContent(
override val htmlDocument: Document?,
override val formattedBody: CharSequence,
override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body
@@ -32,6 +32,8 @@ import io.element.android.features.poll.test.pollcontent.FakePollContentStateFac
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -59,6 +61,8 @@ internal fun aTimelineItemContentFactory(
htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(),
textPillificationHelper = FakeTextPillificationHelper(),
htmlMessageParser = FakeHtmlMessageParser(),
featureFlagService = FakeFeatureFlagService(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
@@ -37,6 +38,10 @@ import io.element.android.features.messages.impl.utils.FakeTextPillificationHelp
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser
import io.element.android.libraries.matrix.api.media.AudioDetails
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
@@ -80,9 +85,11 @@ import kotlinx.coroutines.test.runTest
import org.jsoup.nodes.Document
import org.junit.Assert.fail
import org.junit.Test
import kotlin.jvm.java
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class TimelineItemContentMessageFactoryTest : RobolectricTest() {
@Test
fun `test create OtherMessageType`() = runTest {
@@ -1215,6 +1222,40 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
assertThat(galleryContent.formattedCaption).isNull()
}
@Test
fun `test create will only use the new parser if the feature flag is enabled`() = runTest {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.NewTimelineEventRenderer.key to true
)
)
val sut = createTimelineItemContentMessageFactory(
featureFlagService = featureFlagService
)
val resultWithFeatureFlag = sut.create(
content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
// The message tree is present when the feature flag is enabled
assertThat(resultWithFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java)
assertThat((resultWithFeatureFlag as TimelineItemTextBasedContent).messageTree).isNotNull()
featureFlagService.setFeatureEnabled(FeatureFlags.NewTimelineEventRenderer, false)
val resultWithoutFeatureFlag = sut.create(
content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
// The message tree is null when the feature flag is disabled
assertThat(resultWithoutFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java)
assertThat((resultWithoutFeatureFlag as TimelineItemTextBasedContent).messageTree).isNull()
}
private fun createMessageContent(
body: String = "Body",
inReplyTo: InReplyTo? = null,
@@ -1235,12 +1276,15 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
htmlConverterTransform: (String) -> CharSequence = { it },
domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(),
htmlMessageParser = FakeHtmlMessageParser(parseResult = { DocumentNode(persistentListOf()) }),
featureFlagService = featureFlagService,
)
private fun createStickerContent(