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.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api) implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
@@ -111,4 +112,5 @@ dependencies {
testImplementation(projects.libraries.emoji.impl) testImplementation(projects.libraries.emoji.impl)
testImplementation(projects.libraries.emoji.test) testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.slashcommands.test) testImplementation(projects.libraries.slashcommands.test)
testImplementation(projects.libraries.htmlrenderer.test)
} }
@@ -107,6 +107,7 @@ class PinnedMessagesListPresenter(
val timelineRoomInfo by remember { val timelineRoomInfo by remember {
derivedStateOf { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
currentUserId = room.sessionId,
isDm = roomInfo.isDm, isDm = roomInfo.isDm,
name = roomInfo.name, name = roomInfo.name,
// We don't need to compute those values // 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) { val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
} }
val useNewTimelineEventRenderer by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
}
val timelineProtectionState = timelineProtectionPresenter.present() val timelineProtectionState = timelineProtectionPresenter.present()
@@ -434,6 +437,7 @@ class TimelinePresenter(
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
currentUserId = room.sessionId,
name = roomInfo.name, name = roomInfo.name,
isDm = roomInfo.isDm, isDm = roomInfo.isDm,
userHasPermissionToSendMessage = userEventPermissions.canSendMessage, userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
@@ -463,6 +467,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value, jumpToUnread = jumpToUnread.value,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = ::handleEvent, 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.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId 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.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.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -37,6 +38,8 @@ data class TimelineState(
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean, val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState, val jumpToUnread: JumpToUnreadState,
// When true, formatted message bodies are rendered with the native Compose renderer.
val useNewTimelineEventRenderer: Boolean,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -76,6 +79,7 @@ sealed interface FocusRequestState {
} }
data class TimelineRoomInfo( data class TimelineRoomInfo(
val currentUserId: UserId,
val isDm: Boolean, val isDm: Boolean,
val name: String?, val name: String?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
@@ -63,6 +63,7 @@ fun aTimelineState(
displayJumpToUnread: Boolean = false, displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None, newEventState: NewEventState = NewEventState.None,
useNewTimelineEventRenderer: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -84,6 +85,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread, jumpToUnread = jumpToUnread,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -277,6 +279,7 @@ internal fun aRedactedMessagesGroupedEvents(
} }
internal fun aTimelineRoomInfo( internal fun aTimelineRoomInfo(
currentUserId: UserId = UserId("@user:domain"),
name: String = ROOM_NAME, name: String = ROOM_NAME,
isDm: Boolean = false, isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true, userHasPermissionToSendMessage: Boolean = true,
@@ -284,6 +287,7 @@ internal fun aTimelineRoomInfo(
typingNotificationState: TypingNotificationState = aTypingNotificationState(), typingNotificationState: TypingNotificationState = aTypingNotificationState(),
predecessorRoom: PredecessorRoom? = null, predecessorRoom: PredecessorRoom? = null,
) = TimelineRoomInfo( ) = TimelineRoomInfo(
currentUserId = currentUserId,
isDm = isDm, isDm = isDm,
name = name, name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, 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.components.dialogs.AlertDialog
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.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
LazyColumn( ComposeLocalTimelineEventRendererConfig(
modifier = Modifier isComposeRendererEnabled = state.useNewTimelineEventRenderer,
.fillMaxSize() currentUserId = state.timelineRoomInfo.currentUserId,
.nestedScroll(nestedScrollConnection) )
.testTag(TestTags.timeline), }
state = lazyListState, CompositionLocalProvider(LocalTimelineEventRendererConfig provides composeLocalTimelineEventRendererConfig) {
reverseLayout = true, Box(modifier) {
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp), LazyColumn(
) { modifier = Modifier
items( .fillMaxSize()
items = state.timelineItems, .nestedScroll(nestedScrollConnection)
contentType = { timelineItem -> timelineItem.contentType() }, .testTag(TestTags.timeline),
key = { timelineItem -> timelineItem.identifier() }, state = lazyListState,
) { timelineItem -> reverseLayout = true,
TimelineItemRow( contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
timelineItem = timelineItem, ) {
timelineMode = state.timelineMode, items(
timelineRoomInfo = state.timelineRoomInfo, items = state.timelineItems,
timelineProtectionState = timelineProtectionState, contentType = { timelineItem -> timelineItem.contentType() },
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), key = { timelineItem -> timelineItem.identifier() },
focusedEventId = state.focusedEventId, ) { timelineItem ->
displayThreadSummaries = state.displayThreadSummaries, TimelineItemRow(
onUserDataClick = onUserDataClick, timelineItem = timelineItem,
onLinkClick = onLinkClick, timelineMode = state.timelineMode,
onLinkLongClick = ::onLinkLongClick, timelineRoomInfo = state.timelineRoomInfo,
onContentClick = onContentClick, timelineProtectionState = timelineProtectionState,
onGalleryItemClick = onGalleryItemClick, isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
onLongClick = onMessageLongClick, focusedEventId = state.focusedEventId,
inReplyToClick = ::inReplyToClick, displayThreadSummaries = state.displayThreadSummaries,
onReactionClick = onReactionClick, onUserDataClick = onUserDataClick,
onReactionLongClick = onReactionLongClick, onLinkClick = onLinkClick,
onMoreReactionsClick = onMoreReactionsClick, onLinkLongClick = ::onLinkLongClick,
onReadReceiptClick = onReadReceiptClick, onContentClick = onContentClick,
onSwipeToReply = onSwipeToReply, onGalleryItemClick = onGalleryItemClick,
onJoinCallClick = onJoinCallClick, onLongClick = onMessageLongClick,
eventSink = state.eventSink, 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme 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.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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.LocalUiTestMode 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.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout 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. // 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 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 isInPreview = LocalInspectionMode.current
val emojiOnly = remember(content.body, content.formattedBody, isInPreview) { val emojiOnly = remember(content.body, content.formattedBody, isInPreview) {
content.formattedBody.toString() == content.body && 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.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.core.mimetype.MimeTypes 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.EventId
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.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -71,8 +74,10 @@ class TimelineItemContentMessageFactory(
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper, private val textPillificationHelper: TextPillificationHelper,
private val htmlMessageParser: HtmlMessageParser,
private val featureFlagService: FeatureFlagService,
) { ) {
fun create( suspend fun create(
content: MessageContent, content: MessageContent,
senderId: UserId, senderId: UserId,
senderProfile: ProfileDetails, senderProfile: ProfileDetails,
@@ -93,6 +98,28 @@ class TimelineItemContentMessageFactory(
htmlDocument = dom, htmlDocument = dom,
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, 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 -> { is ImageMessageType -> {
@@ -251,28 +278,15 @@ class TimelineItemContentMessageFactory(
} }
is NoticeMessageType -> { is NoticeMessageType -> {
val body = messageType.body.trimEnd() 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 htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = htmlDocument?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
htmlDocument = htmlDocument, htmlDocument = htmlDocument,
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) messageTree = htmlDocument?.let(htmlMessageParser::parse),
}
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,
) )
} }
is GalleryMessageType -> { is GalleryMessageType -> {
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event 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 io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemEmoteContent(
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val formattedBody: CharSequence, override val formattedBody: CharSequence,
override val isEdited: Boolean, override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent" override val type: String = "TimelineItemEmoteContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body override val plainText: String = htmlDocument?.toPlainText() ?: body
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event 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 io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemNoticeContent(
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val formattedBody: CharSequence, override val formattedBody: CharSequence,
override val isEdited: Boolean, override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent" override val type: String = "TimelineItemNoticeContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body override val plainText: String = htmlDocument?.toPlainText() ?: body
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
/** /**
@@ -24,6 +25,12 @@ sealed interface TimelineItemTextBasedContent :
/** 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 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. /** 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 */ * This can also includes mention spans from permalink parsing */
val formattedBody: CharSequence val formattedBody: CharSequence
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event 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 io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@@ -16,6 +17,7 @@ data class TimelineItemTextContent(
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val formattedBody: CharSequence, override val formattedBody: CharSequence,
override val isEdited: Boolean, override val isEdited: Boolean,
override val messageTree: DocumentNode? = null,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent" override val type: String = "TimelineItemTextContent"
override val plainText: String = htmlDocument?.toPlainText() ?: body 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.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -59,6 +61,8 @@ internal fun aTimelineItemContentFactory(
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
textPillificationHelper = FakeTextPillificationHelper(), textPillificationHelper = FakeTextPillificationHelper(),
htmlMessageParser = FakeHtmlMessageParser(),
featureFlagService = FakeFeatureFlagService(),
), ),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory( 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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent 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.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.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent 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.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes 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.AudioDetails
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo 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.jsoup.nodes.Document
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import kotlin.jvm.java
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class TimelineItemContentMessageFactoryTest : RobolectricTest() { class TimelineItemContentMessageFactoryTest : RobolectricTest() {
@Test @Test
fun `test create OtherMessageType`() = runTest { fun `test create OtherMessageType`() = runTest {
@@ -1215,6 +1222,40 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
assertThat(galleryContent.formattedCaption).isNull() 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( private fun createMessageContent(
body: String = "Body", body: String = "Body",
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
@@ -1235,12 +1276,15 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
htmlConverterTransform: (String) -> CharSequence = { it }, htmlConverterTransform: (String) -> CharSequence = { it },
domConverterTransform: (Document) -> CharSequence = { it.body().html() }, domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(), permalinkParser: FakePermalinkParser = FakePermalinkParser(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = TimelineItemContentMessageFactory( ) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(), textPillificationHelper = FakeTextPillificationHelper(),
htmlMessageParser = FakeHtmlMessageParser(parseResult = { DocumentNode(persistentListOf()) }),
featureFlagService = featureFlagService,
) )
private fun createStickerContent( private fun createStickerContent(
@@ -161,4 +161,12 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = false, isFinished = false,
), ),
NewTimelineEventRenderer(
key = "feature.new_timeline_event_renderer",
title = "New timeline event renderer",
description = "Render formatted message bodies natively with Jetpack Compose instead of the rich text editor view.",
defaultValue = { false },
isFinished = false,
isInLabs = false,
),
} }
@@ -15,5 +15,9 @@ android {
dependencies { dependencies {
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
api(libs.jsoup) api(libs.jsoup)
} }
@@ -0,0 +1,20 @@
/*
* 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.libraries.htmlrenderer.api.spans
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import io.element.android.compound.theme.ElementTheme
/**
* The style for inline code spans.
*/
val InlineCodeSpanStyle: SpanStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = ElementTheme.typography.fontBodySmRegular.fontSize,
)
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.htmlrenderer.impl.renderer package io.element.android.libraries.htmlrenderer.api.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -5,10 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.htmlrenderer.impl.renderer package io.element.android.libraries.htmlrenderer.api.ui
import android.content.ClipData
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -26,20 +30,33 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -48,9 +65,12 @@ import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.BlockNode import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
@@ -64,10 +84,12 @@ import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.launch
/** /**
* Renders a parsed message tree ([DocumentNode]) as native Compose content. * Renders a parsed message tree ([DocumentNode]) as native Compose content.
@@ -76,8 +98,8 @@ import kotlinx.collections.immutable.toImmutableMap
* through the callbacks. It performs no HTML parsing itself. * through the callbacks. It performs no HTML parsing itself.
* *
* @param node the root HTML node of the parsed message tree to render. * @param node the root HTML node of the parsed message tree to render.
* @param modifier applied to the root of the rendered content. * @param modifier the [Modifier] to be applied to the layout.
* @param currentUserId used to style a mention of the current user differently; may be null. * @param currentUserId the current user ID, if known. This can be used to display UI sent by/owned by the current user differently.
* @param onLinkClick invoked with the target URL when a link is tapped. * @param onLinkClick invoked with the target URL when a link is tapped.
* @param onLinkLongClick invoked with the target URL when a link is long-pressed. * @param onLinkLongClick invoked with the target URL when a link is long-pressed.
* @param onMentionClick invoked when a mention pill is tapped. * @param onMentionClick invoked when a mention pill is tapped.
@@ -91,13 +113,20 @@ fun HtmlMessageContent(
node: DocumentNode, node: DocumentNode,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
currentUserId: UserId? = null, currentUserId: UserId? = null,
onLinkClick: (String) -> Unit = {}, onLinkClick: (url: String, text: String) -> Unit = { _, _ -> },
onLinkLongClick: (String) -> Unit = {}, onLinkLongClick: (url: String, text: String) -> Unit = { _, _ -> },
onMentionClick: (MentionNodeContent) -> Unit = {}, onMentionClick: (MentionNodeContent) -> Unit = {},
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) { ) {
val measuredParagraph = remember(node) { node.lastBlockNode() } val measuredParagraph = remember(node) { node.lastBlockNode() }
val context = remember(currentUserId, onLinkClick, onLinkLongClick, onMentionClick, measuredParagraph, onContentLayoutChange) { val context = remember(
currentUserId,
onLinkClick,
onLinkLongClick,
onMentionClick,
measuredParagraph,
onContentLayoutChange
) {
RenderContext( RenderContext(
currentUserId = currentUserId, currentUserId = currentUserId,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@@ -107,17 +136,15 @@ fun HtmlMessageContent(
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
) )
} }
SelectionContainer { BlockNodes(nodes = node.children, context = context, modifier = modifier)
BlockNodes(nodes = node.children, context = context, modifier = modifier)
}
} }
/** Interaction callbacks and state threaded through the render tree. */ /** Interaction callbacks and state threaded through the render tree. */
@Immutable @Immutable
private data class RenderContext( private data class RenderContext(
val currentUserId: UserId?, val currentUserId: UserId?,
val onLinkClick: (String) -> Unit, val onLinkClick: (url: String, text: String) -> Unit,
val onLinkLongClick: (String) -> Unit, val onLinkLongClick: (url: String, text: String) -> Unit,
val onMentionClick: (MentionNodeContent) -> Unit, val onMentionClick: (MentionNodeContent) -> Unit,
// The latest descendant BlockNode of the root DocumentNode // The latest descendant BlockNode of the root DocumentNode
val lastBlockNode: BlockNode?, val lastBlockNode: BlockNode?,
@@ -175,8 +202,9 @@ private fun ParagraphView(
) { ) {
val linkColor = ElementTheme.colors.textLinkExternal val linkColor = ElementTheme.colors.textLinkExternal
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
val styledText = remember(node.text, linkColor, codeBackgroundColor) { val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
node.text.applyInlineStyles(linkColor = linkColor, codeBackgroundColor = codeBackgroundColor) val styledText = remember(node.text, linkColor) {
node.text.applyLinkStyles(linkColor = linkColor)
} }
val inlineContent = rememberMentionInlineContent(node.inlineContent, context) val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) } val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -189,7 +217,9 @@ private fun ParagraphView(
} }
Text( Text(
text = styledText, text = styledText,
modifier = modifier.linkTapHandler(styledText, layoutResult, context), modifier = modifier
.drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
.linkTapHandler(styledText, layoutResult, context),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
inlineContent = inlineContent, inlineContent = inlineContent,
@@ -205,19 +235,69 @@ private fun CodeBlockView(
node: CodeBlockNode, node: CodeBlockNode,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val localClipboard = LocalClipboard.current
val clipDataLabel = stringResource(CommonStrings.common_code_block)
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.border(width = 1.dp, color = ElementTheme.colors.borderInteractiveSecondary, shape = RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary) .background(ElementTheme.colors.bgSubtleSecondary)
.horizontalScroll(rememberScrollState()) .height(IntrinsicSize.Min)
.padding(horizontal = 12.dp, vertical = 8.dp), .combinedClickable(
onClick = {},
onLongClick = {
coroutineScope.launch {
localClipboard.setClipEntry(ClipEntry(ClipData.newPlainText(clipDataLabel, node.code)))
}
},
onLongClickLabel = stringResource(CommonStrings.action_copy_to_clipboard),
),
) { ) {
Text( Text(
modifier = Modifier.horizontalScroll(scrollState).padding(horizontal = 12.dp, vertical = 8.dp),
text = node.code, text = node.code,
style = ElementTheme.typography.fontBodyMdRegular.copy(fontFamily = FontFamily.Monospace), style = ElementTheme.typography.fontBodySmRegular.copy(fontFamily = FontFamily.Monospace),
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
softWrap = false, softWrap = false,
) )
// Previews and screenshots can't render the fading edge gradients properly, everything is obscured by them, so we skip them in the preview mode.
if (LocalInspectionMode.current.not()) {
val progress by remember {
derivedStateOf {
scrollState.value.toFloat() / scrollState.maxValue.coerceAtLeast(1)
}
}
val alpha by animateFloatAsState(targetValue = progress, label = "CodeBlockViewScrollAlpha")
// Draw a fading edge gradient at the left and right of the code block, to hint that it is horizontally scrollable.
Box(
modifier = Modifier
.matchParentSize()
.background(
brush = Brush.horizontalGradient(
colors = listOf(ElementTheme.colors.bgSubtleTertiary.copy(alpha = alpha), Color.Transparent),
startX = 0f,
endX = CodeBlockFadingEdgeWidth.toPx(),
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, ElementTheme.colors.bgSubtleTertiary.copy(alpha = 1f - alpha)),
startX = scrollState.viewportSize - CodeBlockFadingEdgeWidth.toPx(),
endX = scrollState.viewportSize.toFloat(),
)
)
)
}
} }
} }
@@ -257,6 +337,7 @@ private fun ListView(
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
modifier = Modifier.widthIn(min = ListMarkerWidth), modifier = Modifier.widthIn(min = ListMarkerWidth),
textAlign = if (node.ordered) TextAlign.Start else TextAlign.Center,
) )
BlockNodes(nodes = item.children, context = context) BlockNodes(nodes = item.children, context = context)
} }
@@ -324,18 +405,65 @@ private fun rememberMentionInlineContent(
}.toImmutableMap() }.toImmutableMap()
} }
/** Overlays theme-dependent styling (link color, inline-code background) onto the annotated ranges. */ /** Overlays the theme-dependent link color onto the link-annotated ranges. */
private fun AnnotatedString.applyInlineStyles( private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
linkColor: Color,
codeBackgroundColor: Color,
): AnnotatedString {
val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length) val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
val codeRanges = getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, length) if (linkRanges.isEmpty()) return this
if (linkRanges.isEmpty() && codeRanges.isEmpty()) return this
return buildAnnotatedString { return buildAnnotatedString {
append(this@applyInlineStyles) append(this@applyLinkStyles)
linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) } linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) }
codeRanges.forEach { addStyle(SpanStyle(background = codeBackgroundColor), it.start, it.end) } }
}
/**
* Draws a rounded, bordered box behind each inline code ([INLINE_CODE_ANNOTATION_TAG]) range, so
* inline code matches the look of [CodeBlockView] but only covers the annotated text. A range that
* wraps across several lines gets one box per line.
*/
private fun Modifier.drawInlineCodeBackgrounds(
text: AnnotatedString,
layoutResult: State<TextLayoutResult?>,
backgroundColor: Color,
borderColor: Color,
density: Density,
): Modifier = drawBehind {
val layout = layoutResult.value ?: return@drawBehind
val codeRanges = text.getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, text.length)
if (codeRanges.isEmpty()) return@drawBehind
val strokeWidth = InlineCodeBorderWidth.toPx()
val horizontalPadding = InlineCodeHorizontalPadding.toPx()
codeRanges.forEach { range ->
val firstLine = layout.getLineForOffset(range.start)
val lastLine = layout.getLineForOffset(range.end)
for (line in firstLine..lastLine) {
val lineStart = maxOf(range.start, layout.getLineStart(line))
val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true))
if (lineEnd <= lineStart) continue
val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true)
val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true)
val left = minOf(startX, endX) - horizontalPadding
val right = maxOf(startX, endX) + horizontalPadding
val topLeft = Offset(left, layout.getLineTop(line))
val size = Size(right - left, layout.getLineBottom(line) - layout.getLineTop(line))
val shape = RoundedCornerShape(
topStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
topEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
bottomStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
bottomEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
)
val outline = shape.createOutline(
size = size,
layoutDirection = layoutDirection,
density = density,
)
translate(topLeft.x, topLeft.y) {
drawOutline(outline = outline, color = backgroundColor, style = Fill)
drawOutline(outline = outline, color = borderColor, style = Stroke(width = strokeWidth))
}
}
} }
} }
@@ -345,14 +473,21 @@ private fun Modifier.linkTapHandler(
context: RenderContext, context: RenderContext,
): Modifier = pointerInput(text) { ): Modifier = pointerInput(text) {
detectTapGestures( detectTapGestures(
onTap = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkClick) }, onTap = { offset ->
onLongPress = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkLongClick) }, val (url, urlText) = layoutResult.value?.urlAt(offset, text) ?: return@detectTapGestures
context.onLinkClick(url, urlText)
},
onLongPress = { offset ->
val (url, urlText) = layoutResult.value?.urlAt(offset, text) ?: return@detectTapGestures
context.onLinkLongClick(url, urlText)
},
) )
} }
private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): String? { private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): Pair<String, String>? {
val position = getOffsetForPosition(offset) val position = getOffsetForPosition(offset)
return text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull()?.item val urlAnnotation = text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull() ?: return null
return urlAnnotation.item to text.substring(urlAnnotation.start, urlAnnotation.end)
} }
private val BlockSpacing: Dp = 8.dp private val BlockSpacing: Dp = 8.dp
@@ -360,3 +495,11 @@ private val ListItemSpacing: Dp = 4.dp
private val ListMarkerWidth: Dp = 24.dp private val ListMarkerWidth: Dp = 24.dp
private val PillPaddingHorizontal: Dp = 6.dp private val PillPaddingHorizontal: Dp = 6.dp
private val PillPaddingVertical: Dp = 2.dp private val PillPaddingVertical: Dp = 2.dp
/** Rounded, bordered box drawn behind inline code. */
private val InlineCodeCornerRadius: Dp = 6.dp
private val InlineCodeBorderWidth: Dp = 1.dp
private val InlineCodeHorizontalPadding: Dp = 2.dp
/** The width of the fading edge gradient drawn at the left and right of a [CodeBlockView]. */
private val CodeBlockFadingEdgeWidth: Dp = 32.dp
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.htmlrenderer.impl.renderer package io.element.android.libraries.htmlrenderer.api.ui
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
@@ -33,6 +32,7 @@ import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -46,8 +46,8 @@ internal fun HtmlMessageContentPreview(
) = ElementPreview { ) = ElementPreview {
HtmlMessageContent( HtmlMessageContent(
node = node, node = node,
modifier = Modifier.padding(16.dp),
currentUserId = UserId("@me:example.org"), currentUserId = UserId("@me:example.org"),
modifier = Modifier.padding(16.dp),
) )
} }
@@ -62,6 +62,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
nestedQuotes(), nestedQuotes(),
codeBlock(), codeBlock(),
mention(), mention(),
mentionOwn(),
nestedList(), nestedList(),
nestedListWithComplexContents(), nestedListWithComplexContents(),
) )
@@ -86,7 +87,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
buildAnnotatedString { buildAnnotatedString {
append("Run ") append("Run ")
val start = length val start = length
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append("git status") } withStyle(InlineCodeSpanStyle) { append("git status") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length) addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(" to see changes") append(" to see changes")
} }
@@ -99,9 +100,9 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
buildAnnotatedString { buildAnnotatedString {
append("Trying ") append("Trying ")
val start = length val start = length
withStyle( withStyle(InlineCodeSpanStyle) {
SpanStyle(fontFamily = FontFamily.Monospace) append("inline code when it needs to be wrapped in several lines, just to check what it looks like")
) { append("inline code when it needs to be wrapped in several lines, just to check what it looks like") } }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length) addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(", is it good?") append(", is it good?")
} }
@@ -153,6 +154,19 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
), ),
) )
private fun mentionOwn() = document(
paragraph(
text = buildAnnotatedString {
append("Hey ")
appendInlineContent(MENTION_ID, "@me")
append(", welcome!")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@me", userId = UserId("@me:example.org")),
),
),
)
private fun nestedList() = document( private fun nestedList() = document(
ListNode( ListNode(
ordered = false, ordered = false,
@@ -26,7 +26,6 @@ setupDependencyInjection()
dependencies { dependencies {
api(projects.libraries.htmlrenderer.api) api(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(libs.jsoup) implementation(libs.jsoup)
@@ -10,7 +10,6 @@ package io.element.android.libraries.htmlrenderer.impl
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@@ -27,6 +26,7 @@ import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -158,7 +158,7 @@ class DefaultHtmlMessageParser(
val start = builder.length val start = builder.length
appendChildren(element) appendChildren(element)
if (builder.length > start) { if (builder.length > start) {
builder.addStyle(SpanStyle(fontFamily = FontFamily.Monospace), start, builder.length) builder.addStyle(InlineCodeSpanStyle, start, builder.length)
builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length) builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length)
} }
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2026 Element Creations Ltd. * Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd. * Copyright 2023-2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
@@ -108,6 +108,7 @@
<string name="action_copy_link">"Copy link"</string> <string name="action_copy_link">"Copy link"</string>
<string name="action_copy_link_to_message">"Copy link to message"</string> <string name="action_copy_link_to_message">"Copy link to message"</string>
<string name="action_copy_text">"Copy text"</string> <string name="action_copy_text">"Copy text"</string>
<string name="action_copy_to_clipboard">"Copy to clipboard"</string>
<string name="action_create">"Create"</string> <string name="action_create">"Create"</string>
<string name="action_create_room">"Create room"</string> <string name="action_create_room">"Create room"</string>
<string name="action_create_space">"Create space"</string> <string name="action_create_space">"Create space"</string>
@@ -240,6 +241,7 @@
<string name="common_call_started">"Call started"</string> <string name="common_call_started">"Call started"</string>
<string name="common_call_you_declined">"You declined a call"</string> <string name="common_call_you_declined">"You declined a call"</string>
<string name="common_chat_backup">"Chat backup"</string> <string name="common_chat_backup">"Chat backup"</string>
<string name="common_code_block">"Code block"</string>
<string name="common_copied_to_clipboard">"Copied to clipboard"</string> <string name="common_copied_to_clipboard">"Copied to clipboard"</string>
<string name="common_copyright">"Copyright"</string> <string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string> <string name="common_creating_room">"Creating room…"</string>
@@ -20,9 +20,9 @@ import org.gradle.kotlin.dsl.closureOf
import org.gradle.kotlin.dsl.project import org.gradle.kotlin.dsl.project
private fun DependencyHandlerScope.implementation(dependency: Any) = dependencies.add("implementation", dependency) private fun DependencyHandlerScope.implementation(dependency: Any) = dependencies.add("implementation", dependency)
internal fun DependencyHandler.implementation(dependency: Any) = add("implementation", dependency)
private fun DependencyHandlerScope.testImplementation(dependency: Any) = dependencies.add("testImplementation", dependency) private fun DependencyHandlerScope.testImplementation(dependency: Any) = dependencies.add("testImplementation", dependency)
private fun DependencyHandlerScope.testReleaseImplementation(dependency: Any) = dependencies.add("testReleaseImplementation", dependency) private fun DependencyHandlerScope.testReleaseImplementation(dependency: Any) = dependencies.add("testReleaseImplementation", dependency)
internal fun DependencyHandler.implementation(dependency: Any) = add("implementation", dependency)
// Implementation + config block // Implementation + config block
private fun DependencyHandlerScope.implementation( private fun DependencyHandlerScope.implementation(
@@ -94,6 +94,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:network")) implementation(project(":libraries:network"))
implementation(project(":libraries:core")) implementation(project(":libraries:core"))
implementation(project(":libraries:eventformatter:impl")) implementation(project(":libraries:eventformatter:impl"))
implementation(project(":libraries:htmlrenderer:impl"))
implementation(project(":libraries:indicator:impl")) implementation(project(":libraries:indicator:impl"))
implementation(project(":libraries:permissions:impl")) implementation(project(":libraries:permissions:impl"))
implementation(project(":libraries:audio:impl")) implementation(project(":libraries:audio:impl"))
+1
View File
@@ -241,6 +241,7 @@ Compose:
- LocalBuildMeta - LocalBuildMeta
- LocalUiTestMode - LocalUiTestMode
- LocalSdkIntVersionProvider - LocalSdkIntVersionProvider
- LocalTimelineEventRendererConfig
CompositionLocalNaming: CompositionLocalNaming:
active: true active: true
ContentEmitterReturningValues: ContentEmitterReturningValues: