mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +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(
|
||||
|
||||
+8
@@ -161,4 +161,12 @@ enum class FeatureFlags(
|
||||
defaultValue = { 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 {
|
||||
api(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiCommon)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
api(libs.jsoup)
|
||||
}
|
||||
|
||||
+20
@@ -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,
|
||||
)
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 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.layout.Box
|
||||
+174
-31
@@ -5,10 +5,14 @@
|
||||
* 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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
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.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.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
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.Placeholder
|
||||
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.font.FontFamily
|
||||
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 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.htmlrenderer.api.BlockNode
|
||||
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.ui.common.layout.ContentAvoidingLayout
|
||||
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.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param node the root HTML node of the parsed message tree to render.
|
||||
* @param modifier applied to the root of the rendered content.
|
||||
* @param currentUserId used to style a mention of the current user differently; may be null.
|
||||
* @param modifier the [Modifier] to be applied to the layout.
|
||||
* @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 onLinkLongClick invoked with the target URL when a link is long-pressed.
|
||||
* @param onMentionClick invoked when a mention pill is tapped.
|
||||
@@ -91,13 +113,20 @@ fun HtmlMessageContent(
|
||||
node: DocumentNode,
|
||||
modifier: Modifier = Modifier,
|
||||
currentUserId: UserId? = null,
|
||||
onLinkClick: (String) -> Unit = {},
|
||||
onLinkLongClick: (String) -> Unit = {},
|
||||
onLinkClick: (url: String, text: String) -> Unit = { _, _ -> },
|
||||
onLinkLongClick: (url: String, text: String) -> Unit = { _, _ -> },
|
||||
onMentionClick: (MentionNodeContent) -> Unit = {},
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
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(
|
||||
currentUserId = currentUserId,
|
||||
onLinkClick = onLinkClick,
|
||||
@@ -107,17 +136,15 @@ fun HtmlMessageContent(
|
||||
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. */
|
||||
@Immutable
|
||||
private data class RenderContext(
|
||||
val currentUserId: UserId?,
|
||||
val onLinkClick: (String) -> Unit,
|
||||
val onLinkLongClick: (String) -> Unit,
|
||||
val onLinkClick: (url: String, text: String) -> Unit,
|
||||
val onLinkLongClick: (url: String, text: String) -> Unit,
|
||||
val onMentionClick: (MentionNodeContent) -> Unit,
|
||||
// The latest descendant BlockNode of the root DocumentNode
|
||||
val lastBlockNode: BlockNode?,
|
||||
@@ -175,8 +202,9 @@ private fun ParagraphView(
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val styledText = remember(node.text, linkColor, codeBackgroundColor) {
|
||||
node.text.applyInlineStyles(linkColor = linkColor, codeBackgroundColor = codeBackgroundColor)
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(node.text, linkColor) {
|
||||
node.text.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
@@ -189,7 +217,9 @@ private fun ParagraphView(
|
||||
}
|
||||
Text(
|
||||
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,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
inlineContent = inlineContent,
|
||||
@@ -205,19 +235,69 @@ private fun CodeBlockView(
|
||||
node: CodeBlockNode,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val localClipboard = LocalClipboard.current
|
||||
val clipDataLabel = stringResource(CommonStrings.common_code_block)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderInteractiveSecondary, shape = RoundedCornerShape(8.dp))
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
.height(IntrinsicSize.Min)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
localClipboard.setClipEntry(ClipEntry(ClipData.newPlainText(clipDataLabel, node.code)))
|
||||
}
|
||||
},
|
||||
onLongClickLabel = stringResource(CommonStrings.action_copy_to_clipboard),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.horizontalScroll(scrollState).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
text = node.code,
|
||||
style = ElementTheme.typography.fontBodyMdRegular.copy(fontFamily = FontFamily.Monospace),
|
||||
style = ElementTheme.typography.fontBodySmRegular.copy(fontFamily = FontFamily.Monospace),
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
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,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.widthIn(min = ListMarkerWidth),
|
||||
textAlign = if (node.ordered) TextAlign.Start else TextAlign.Center,
|
||||
)
|
||||
BlockNodes(nodes = item.children, context = context)
|
||||
}
|
||||
@@ -324,18 +405,65 @@ private fun rememberMentionInlineContent(
|
||||
}.toImmutableMap()
|
||||
}
|
||||
|
||||
/** Overlays theme-dependent styling (link color, inline-code background) onto the annotated ranges. */
|
||||
private fun AnnotatedString.applyInlineStyles(
|
||||
linkColor: Color,
|
||||
codeBackgroundColor: Color,
|
||||
): AnnotatedString {
|
||||
/** Overlays the theme-dependent link color onto the link-annotated ranges. */
|
||||
private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
|
||||
val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
|
||||
val codeRanges = getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, length)
|
||||
if (linkRanges.isEmpty() && codeRanges.isEmpty()) return this
|
||||
if (linkRanges.isEmpty()) return this
|
||||
return buildAnnotatedString {
|
||||
append(this@applyInlineStyles)
|
||||
append(this@applyLinkStyles)
|
||||
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,
|
||||
): Modifier = pointerInput(text) {
|
||||
detectTapGestures(
|
||||
onTap = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkClick) },
|
||||
onLongPress = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkLongClick) },
|
||||
onTap = { offset ->
|
||||
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)
|
||||
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
|
||||
@@ -360,3 +495,11 @@ private val ListItemSpacing: Dp = 4.dp
|
||||
private val ListMarkerWidth: Dp = 24.dp
|
||||
private val PillPaddingHorizontal: Dp = 6.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
|
||||
+21
-7
@@ -5,7 +5,7 @@
|
||||
* 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.text.appendInlineContent
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
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.FontWeight
|
||||
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.ParagraphNode
|
||||
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 kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -46,8 +46,8 @@ internal fun HtmlMessageContentPreview(
|
||||
) = ElementPreview {
|
||||
HtmlMessageContent(
|
||||
node = node,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
currentUserId = UserId("@me:example.org"),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
nestedQuotes(),
|
||||
codeBlock(),
|
||||
mention(),
|
||||
mentionOwn(),
|
||||
nestedList(),
|
||||
nestedListWithComplexContents(),
|
||||
)
|
||||
@@ -86,7 +87,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
buildAnnotatedString {
|
||||
append("Run ")
|
||||
val start = length
|
||||
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append("git status") }
|
||||
withStyle(InlineCodeSpanStyle) { append("git status") }
|
||||
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
|
||||
append(" to see changes")
|
||||
}
|
||||
@@ -99,9 +100,9 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
buildAnnotatedString {
|
||||
append("Trying ")
|
||||
val start = length
|
||||
withStyle(
|
||||
SpanStyle(fontFamily = FontFamily.Monospace)
|
||||
) { append("inline code when it needs to be wrapped in several lines, just to check what it looks like") }
|
||||
withStyle(InlineCodeSpanStyle) {
|
||||
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)
|
||||
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(
|
||||
ListNode(
|
||||
ordered = false,
|
||||
@@ -26,7 +26,6 @@ setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.htmlrenderer.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.uiCommon)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
+2
-2
@@ -10,7 +10,6 @@ package io.element.android.libraries.htmlrenderer.impl
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.FontWeight
|
||||
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.ParagraphNode
|
||||
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.PermalinkParser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -158,7 +158,7 @@ class DefaultHtmlMessageParser(
|
||||
val start = builder.length
|
||||
appendChildren(element)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* 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_to_message">"Copy link to message"</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_room">"Create room"</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_you_declined">"You declined a call"</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_copyright">"Copyright"</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
|
||||
|
||||
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.testReleaseImplementation(dependency: Any) = dependencies.add("testReleaseImplementation", dependency)
|
||||
internal fun DependencyHandler.implementation(dependency: Any) = add("implementation", dependency)
|
||||
|
||||
// Implementation + config block
|
||||
private fun DependencyHandlerScope.implementation(
|
||||
@@ -94,6 +94,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||
implementation(project(":libraries:network"))
|
||||
implementation(project(":libraries:core"))
|
||||
implementation(project(":libraries:eventformatter:impl"))
|
||||
implementation(project(":libraries:htmlrenderer:impl"))
|
||||
implementation(project(":libraries:indicator:impl"))
|
||||
implementation(project(":libraries:permissions:impl"))
|
||||
implementation(project(":libraries:audio:impl"))
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -241,6 +241,7 @@ Compose:
|
||||
- LocalBuildMeta
|
||||
- LocalUiTestMode
|
||||
- LocalSdkIntVersionProvider
|
||||
- LocalTimelineEventRendererConfig
|
||||
CompositionLocalNaming:
|
||||
active: true
|
||||
ContentEmitterReturningValues:
|
||||
|
||||
Reference in New Issue
Block a user