mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
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:
committed by
GitHub
parent
0ea76ecebf
commit
4e47c6170e
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+31
@@ -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?,
|
||||
)
|
||||
+5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+80
-73
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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 &&
|
||||
|
||||
+31
-17
@@ -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 -> {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+4
@@ -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(
|
||||
|
||||
+44
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user