From 4e47c6170e70bbc24e89351699d72467f0f49b4d Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 22 Sep 2026 10:26:35 +0200 Subject: [PATCH] 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 --- features/messages/impl/build.gradle.kts | 2 + .../list/PinnedMessagesListPresenter.kt | 1 + .../LocalComposeTimelineEventRenderer.kt | 31 +++ .../impl/timeline/TimelinePresenter.kt | 5 + .../messages/impl/timeline/TimelineState.kt | 4 + .../impl/timeline/TimelineStateProvider.kt | 4 + .../messages/impl/timeline/TimelineView.kt | 153 ++++++------- .../components/event/TimelineItemTextView.kt | 27 +++ .../TimelineItemContentMessageFactory.kt | 48 ++-- .../model/event/TimelineItemEmoteContent.kt | 2 + .../model/event/TimelineItemNoticeContent.kt | 2 + .../event/TimelineItemTextBasedContent.kt | 7 + .../model/event/TimelineItemTextContent.kt | 2 + .../fixtures/TimelineItemsFactoryFixtures.kt | 4 + .../TimelineItemContentMessageFactoryTest.kt | 44 ++++ .../libraries/featureflag/api/FeatureFlags.kt | 8 + libraries/htmlrenderer/api/build.gradle.kts | 4 + .../api/spans/InlineCodeSpanStyle.kt | 20 ++ ...ontentAvoidingHtmlMessageContentPreview.kt | 2 +- .../api/ui}/HtmlMessageContent.kt | 205 +++++++++++++++--- .../api/ui}/HtmlMessageContentPreview.kt | 28 ++- libraries/htmlrenderer/impl/build.gradle.kts | 1 - .../impl/DefaultHtmlMessageParser.kt | 4 +- .../ui/common/layout/ContentAvoidingLayout.kt | 2 +- .../src/main/res/values/localazy.xml | 2 + .../kotlin/extension/DependencyHandleScope.kt | 3 +- ...er.api.ui_HtmlMessageContent_Day_0_en.png} | 0 ...er.api.ui_HtmlMessageContent_Day_10_en.png | 3 + ...rer.api.ui_HtmlMessageContent_Day_1_en.png | 3 + ...rer.api.ui_HtmlMessageContent_Day_2_en.png | 3 + ...er.api.ui_HtmlMessageContent_Day_3_en.png} | 0 ...er.api.ui_HtmlMessageContent_Day_4_en.png} | 0 ...rer.api.ui_HtmlMessageContent_Day_5_en.png | 3 + ...rer.api.ui_HtmlMessageContent_Day_6_en.png | 3 + ...er.api.ui_HtmlMessageContent_Day_7_en.png} | 0 ...rer.api.ui_HtmlMessageContent_Day_8_en.png | 3 + ...rer.api.ui_HtmlMessageContent_Day_9_en.png | 3 + ....api.ui_HtmlMessageContent_Night_0_en.png} | 0 ....api.ui_HtmlMessageContent_Night_10_en.png | 3 + ...r.api.ui_HtmlMessageContent_Night_1_en.png | 3 + ...r.api.ui_HtmlMessageContent_Night_2_en.png | 3 + ....api.ui_HtmlMessageContent_Night_3_en.png} | 0 ....api.ui_HtmlMessageContent_Night_4_en.png} | 0 ...r.api.ui_HtmlMessageContent_Night_5_en.png | 3 + ...r.api.ui_HtmlMessageContent_Night_6_en.png | 3 + ....api.ui_HtmlMessageContent_Night_7_en.png} | 0 ...r.api.ui_HtmlMessageContent_Night_8_en.png | 3 + ...r.api.ui_HtmlMessageContent_Night_9_en.png | 3 + ...l.renderer_HtmlMessageContent_Day_1_en.png | 3 - ...l.renderer_HtmlMessageContent_Day_2_en.png | 3 - ...l.renderer_HtmlMessageContent_Day_5_en.png | 3 - ...l.renderer_HtmlMessageContent_Day_6_en.png | 3 - ...l.renderer_HtmlMessageContent_Day_8_en.png | 3 - ...l.renderer_HtmlMessageContent_Day_9_en.png | 3 - ...renderer_HtmlMessageContent_Night_1_en.png | 3 - ...renderer_HtmlMessageContent_Night_2_en.png | 3 - ...renderer_HtmlMessageContent_Night_5_en.png | 3 - ...renderer_HtmlMessageContent_Night_6_en.png | 3 - ...renderer_HtmlMessageContent_Night_8_en.png | 3 - ...renderer_HtmlMessageContent_Night_9_en.png | 3 - tools/detekt/detekt.yml | 1 + 61 files changed, 524 insertions(+), 170 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt create mode 100644 libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt rename libraries/htmlrenderer/{impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer => api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui}/ContentAvoidingHtmlMessageContentPreview.kt (97%) rename libraries/htmlrenderer/{impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer => api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui}/HtmlMessageContent.kt (59%) rename libraries/htmlrenderer/{impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer => api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui}/HtmlMessageContentPreview.kt (89%) rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_0_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_0_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_2_en.png rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_3_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_4_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_4_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_6_en.png rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_7_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_7_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_9_en.png rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_0_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_0_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_2_en.png rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_3_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_4_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_4_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_6_en.png rename tests/uitests/src/test/snapshots/images/{libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_7_en.png => libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_7_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_9_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_5_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_8_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_9_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_5_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_8_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_9_en.png diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 58c71a96f6..882d9f1f97 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 748fa084f8..8983342cd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt new file mode 100644 index 0000000000..840ee8c5c7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt @@ -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?, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 2fc4351cea..4580f77d59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 1ebc96f15f..9376747715 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 89545b5d0b..af5bca7b2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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().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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 5e7fd34723..ed53a9f885 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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, - ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index bb8e30aca3..8a4f2635a1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -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 && diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 75966db1a3..c3586ba225 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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 -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt index 28946e8eea..c73c3a3480 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt index bb6f6db685..a88ac11e82 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt index 4d941e73db..fc6d1da4df 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt index 2de0d6cead..a7701b044d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index fb39e67fc5..45d88ad765 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -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( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 15b624aeff..cf80e028e7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -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( diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index af7ccef97f..28a03def12 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -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, + ), } diff --git a/libraries/htmlrenderer/api/build.gradle.kts b/libraries/htmlrenderer/api/build.gradle.kts index a475be7eb0..0a111fec36 100644 --- a/libraries/htmlrenderer/api/build.gradle.kts +++ b/libraries/htmlrenderer/api/build.gradle.kts @@ -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) } diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt new file mode 100644 index 0000000000..1f3010660c --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt @@ -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, +) diff --git a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/ContentAvoidingHtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt similarity index 97% rename from libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/ContentAvoidingHtmlMessageContentPreview.kt rename to libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt index a60e971595..9bba8807af 100644 --- a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/ContentAvoidingHtmlMessageContentPreview.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt @@ -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 diff --git a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt similarity index 59% rename from libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContent.kt rename to libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index 0e624d5998..25b1f95338 100644 --- a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -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(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, + 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? { 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 diff --git a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt similarity index 89% rename from libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContentPreview.kt rename to libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt index 81a92274f9..117764f02d 100644 --- a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/renderer/HtmlMessageContentPreview.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt @@ -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 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) } } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt index 7157e38a7f..3ba0ad5942 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt @@ -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. diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index b5867f5c08..9a51005943 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -108,6 +108,7 @@ "Copy link" "Copy link to message" "Copy text" + "Copy to clipboard" "Create" "Create room" "Create space" @@ -240,6 +241,7 @@ "Call started" "You declined a call" "Chat backup" + "Code block" "Copied to clipboard" "Copyright" "Creating room…" diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index deed163491..d1e2dd8c4a 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -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")) diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_10_en.png new file mode 100644 index 0000000000..54a03450e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a38304be44c5fcf2a9115acef98d71d8b9fe03dcbefb0cfa76546e39911ab8a +size 21732 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_1_en.png new file mode 100644 index 0000000000..104effeaec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18579f8262a4a55997ef8ab3e085ce7d32e97b454b999ea685656a9b67163f93 +size 9363 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_2_en.png new file mode 100644 index 0000000000..989ac136d6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f6aa713c17f33c96e75b623daba8b45c29c147b46724b2fba4fa1fc662e1f95 +size 19547 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_3_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_4_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_5_en.png new file mode 100644 index 0000000000..fe0cd344e5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df17f77b172f58c725a6c30c092ae3fdb2e561785ffe124bdc4fa08fc686f794 +size 21035 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_6_en.png new file mode 100644 index 0000000000..04814e712c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6d17d65431bc2c7728a3ad5c6d2fdedbf45658f51ed8f6d73bc26de799b79af +size 10021 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_7_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_8_en.png new file mode 100644 index 0000000000..b1f89a1f45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e457089542659d700e4493bf1d345f79d7c829cbbded2d4337882a7e45f6808 +size 8783 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_9_en.png new file mode 100644 index 0000000000..61c461a1e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:780f661eca8275cc2fb70f7b504f8f95f79f387d6e72b2702cbb5da150c081a0 +size 8059 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_10_en.png new file mode 100644 index 0000000000..079a0c20cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a74061cb811c957c9b00520eb37566b58168b31ebe74a06506e8ed6c76e03b5f +size 20803 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_1_en.png new file mode 100644 index 0000000000..7f8a883927 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c07fe4ad8f0b14daec7ded50f1886a83868be166a7f42a5dacea985538d97b3 +size 8924 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_2_en.png new file mode 100644 index 0000000000..18345ce06c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccbda5890ab6e53c66483ed169175d223525ac6c4bd7dc6f845c10a9f2531e65 +size 18802 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_3_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_4_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_5_en.png new file mode 100644 index 0000000000..a3c904e385 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:323355db6c969094011a384c9e1f939d056415309400a270a56a6842a7d5072f +size 20153 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_6_en.png new file mode 100644 index 0000000000..64a6c9553e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79f9cfc67be1aa32d6eb05bcfe8bab6b4bff43ab8757668c3ea0ebccf5383089 +size 9806 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_7_en.png rename to tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_8_en.png new file mode 100644 index 0000000000..30fad755ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3db9c79b4e0ce71b93da5aba58fd9bdcc977a0a979eb89f8027b3ca378f25d5e +size 8360 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_9_en.png new file mode 100644 index 0000000000..7fbf2c7e88 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.api.ui_HtmlMessageContent_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc284fea350c1bfdbd73df2a0065484f77361c707f4ee8d60a29dafde07e8af0 +size 7774 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_1_en.png deleted file mode 100644 index 5f268c9c77..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e9e8ffa3026ed91ec3be87c095cf69b576bd850b9dd62eb75ac9714fc7c2a959 -size 9107 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_2_en.png deleted file mode 100644 index a62999a521..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c83a1453cfae40896c3235108b352ec423899e822b421fc70f5ced165b74fad -size 20981 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_5_en.png deleted file mode 100644 index 2bcf5a29a5..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_5_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4df912c480f81f6464d5727c1141984a0c664ce2f3f02fc9960399734eb25c83 -size 21663 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_6_en.png deleted file mode 100644 index 3b65726816..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6a209c616b0da57ef13f9b114c0c650cd8872eff408b5d4eed55a522fcafb18e -size 10570 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_8_en.png deleted file mode 100644 index 219148f764..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_8_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e93932d15c7f6101d48ebee1bc149d742bfa2670be5463cccc86b42d4a4d045e -size 8051 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_9_en.png deleted file mode 100644 index 53c32b4c1f..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Day_9_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a2735325657df1308569d162456bb82c65c1a1d03536e11300f56ed21e5a6ac -size 21853 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_1_en.png deleted file mode 100644 index 4b323d17c2..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:10068483adef8e10071425c36ca98b989935364b5530f9646c1e783fb5825eb0 -size 8780 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_2_en.png deleted file mode 100644 index a9f0369768..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6f965fb03c6642fe32f669728088e622b65c8cddab54c650a0a2ca6ec2a9ece7 -size 20394 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_5_en.png deleted file mode 100644 index 6c7a13c946..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_5_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:066b70752de87c31d89586aae89b047d3c7f44e022ba67587b0fb4200cde0652 -size 20769 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_6_en.png deleted file mode 100644 index 56a774a0d3..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:86d72de3939631fc603e4b0719f78b2b43d8d3f4336aea23f71930cc9ba16a65 -size 10467 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_8_en.png deleted file mode 100644 index 6c6e821925..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_8_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5d079c04229de41109bc4472f70d67f22b6c2e070adecae87fc662fab532169 -size 7770 diff --git a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_9_en.png deleted file mode 100644 index 28cdd376fd..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.htmlrenderer.impl.renderer_HtmlMessageContent_Night_9_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fa8a4442fcea3d34ba9e9bb67afb90b15e0b683765a31c43ff00e8caab90fc27 -size 20931 diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 9c9bcd248b..8c400301ab 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -241,6 +241,7 @@ Compose: - LocalBuildMeta - LocalUiTestMode - LocalSdkIntVersionProvider + - LocalTimelineEventRendererConfig CompositionLocalNaming: active: true ContentEmitterReturningValues: