Use RTE TextView for timeline text messages, add mention pills to messages (#1990)

* Add `formattedBody` to `TimelineItemTextBasedContent`.

This is pre-computed when timeline events are being mapped from the Rust SDK.

* Update `HtmlConverterProvider` styles.

* Improve `MentionSpan` to add missing `@` or `#` if needed

* Replace `HtmlDocument` with the `TextView` based component

* Improve extra padding calculation for timestamp by rounding the float offset result instead of truncating it.

* Remove composer line height workaround

* Use `ElementRichTextEditorStyle` instead of `RichTextEditorDefaults` for the theming

* Use slightly different styles for composer and messages (top/bottom line height discrepancies, mostly).

* Add `formattedBody` to notice and emote events.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-12-13 18:09:53 +01:00 committed by GitHub
parent 5cf78b696b
commit ab58cc2ed5
232 changed files with 754 additions and 1124 deletions

3
changelog.d/1433.feature Normal file
View file

@ -0,0 +1,3 @@
Use the RTE library `TextView` to render text events in the timeline.
Add support for mention pills - with no interaction yet.

View file

@ -15,7 +15,7 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.api.timeline
import androidx.compose.runtime.Composable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
interface HtmlConverterProvider {
@Composable
fun Update(currentUserId: UserId)
fun provide(): HtmlConverter
}

View file

@ -73,6 +73,7 @@ dependencies {
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage) implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings) implementation(libs.matrix.emojibase.bindings)
api(libs.matrix.richtexteditor.compose)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -97,6 +98,8 @@ dependencies {
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.junitext) testImplementation(libs.test.junitext)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -107,6 +108,7 @@ class MessagesPresenter @AssistedInject constructor(
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val preferencesStore: PreferencesStore, private val preferencesStore: PreferencesStore,
private val featureFlagsService: FeatureFlagService, private val featureFlagsService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val currentSessionIdHolder: CurrentSessionIdHolder, private val currentSessionIdHolder: CurrentSessionIdHolder,
@ -121,6 +123,8 @@ class MessagesPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): MessagesState { override fun present(): MessagesState {
htmlConverterProvider.Update(currentUserId = currentSessionIdHolder.current)
val roomInfo by room.roomInfoFlow.collectAsState(null) val roomInfo by room.roomInfoFlow.collectAsState(null)
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present() val composerState = composerPresenter.present()

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay
import io.element.android.wysiwyg.utils.HtmlConverter
import uniffi.wysiwyg_composer.newMentionDetector
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor(): HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable
override fun Update(currentUserId: UserId) {
val isInEditMode = LocalInspectionMode.current
val mentionDetector = remember(isInEditMode) {
if (isInEditMode) { null } else { newMentionDetector() }
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanProvider) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#"))
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
}
},
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
).apply {
configureWith(editorStyle)
}
}
}
override fun provide(): HtmlConverter {
return htmlConverter.value ?: error("HtmlConverter wasn't instantiated. Make sure to call HtmlConverterProvider.Update() first.")
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -48,6 +49,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -79,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -93,9 +96,12 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -305,8 +311,6 @@ private fun TimelineItemEventRowContent(
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClicked, inReplyToClick = inReplyToClicked,
onTimestampClicked = { onTimestampClicked = {
@ -380,8 +384,6 @@ private fun MessageSenderInformation(
@Composable @Composable
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
@ -473,6 +475,7 @@ private fun MessageEventBubbleContent(
inReplyToDetails: InReplyToDetails?, inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier val timestampLayoutModifier: Modifier
val contentModifier: Modifier val contentModifier: Modifier
when { when {
@ -508,9 +511,18 @@ private fun MessageEventBubbleContent(
content = event.content, content = event.content,
isMine = event.isMine, isMine = event.isMine,
isEditable = event.isEditable, isEditable = event.isEditable,
interactionSource = interactionSource, onLinkClicked = { url ->
onClick = onMessageClick, Timber.d("Clicked on: $url")
onLongClick = onMessageLongClick, when (PermalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
// TODO open member details
}
is PermalinkData.FallbackLink -> {
context.openUrlInExternalApp(url)
}
else -> Unit // TODO handle other types of links, as room ones
}
},
extraPadding = event.toExtraPadding(), extraPadding = event.toExtraPadding(),
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,

View file

@ -82,9 +82,7 @@ fun TimelineItemStateEventRow(
content = event.content, content = event.content,
isMine = event.isMine, isMine = event.isMine,
isEditable = event.isEditable, isEditable = event.isEditable,
interactionSource = interactionSource, onLinkClicked = {},
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding, extraPadding = noExtraPadding,
eventSink = eventSink, eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.roundToInt
// Allow to not overlap the timestamp with the text, in the message bubble. // Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case. // Compute the size of the worst case.
@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
fun ExtraPadding.getStr(fontSize: TextUnit): String { fun ExtraPadding.getStr(fontSize: TextUnit): String {
if (nbChars == 0) return "" if (nbChars == 0) return ""
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1 val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).roundToInt() + 1
// A space and some unbreakable spaces // A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces) return " " + "\u00A0".repeat(nbOfSpaces)
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
@ -43,10 +42,8 @@ fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
isMine: Boolean, isMine: Boolean,
isEditable: Boolean, isEditable: Boolean,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
onClick: () -> Unit, onLinkClicked: (url: String) -> Unit,
onLongClick: () -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -65,10 +62,8 @@ fun TimelineItemEventContentView(
is TimelineItemTextBasedContent -> TimelineItemTextView( is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content, content = content,
extraPadding = extraPadding, extraPadding = extraPadding,
interactionSource = interactionSource,
modifier = modifier, modifier = modifier,
onTextClicked = onClick, onLinkClicked = onLinkClicked,
onTextLongClicked = onLongClick
) )
is TimelineItemUnknownContent -> TimelineItemUnknownView( is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content, content = content,

View file

@ -16,56 +16,52 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource import android.text.SpannableString
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.sp import androidx.core.text.buildSpannedString
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toAnnotatedString import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
fun TimelineItemTextView( fun TimelineItemTextView(
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
onTextClicked: () -> Unit, onLinkClicked: (String) -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) { CompositionLocalProvider(
val htmlDocument = content.htmlDocument LocalContentColor provides ElementTheme.colors.textPrimary,
if (htmlDocument != null) { LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
HtmlDocument( ) {
document = htmlDocument, val fontSize = LocalTextStyle.current.fontSize
extraPadding = extraPadding,
modifier = modifier, val formattedBody = content.formattedBody
onTextClicked = onTextClicked, val body = SpannableString(formattedBody ?: content.body)
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource Box(modifier) {
) val textWithPadding = remember(body, fontSize) {
} else { buildSpannedString {
Box(modifier) { append(body)
val textWithPadding = remember(content.body) { append(extraPadding.getStr(fontSize))
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
} }
ClickableLinkText(
text = textWithPadding,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
} }
EditorStyledText(
text = textWithPadding,
onLinkClickedListener = onLinkClicked,
style = ElementRichTextEditorStyle.textStyle(),
)
} }
} }
} }
@ -77,9 +73,7 @@ internal fun TimelineItemTextViewPreview(
) = ElementPreview { ) = ElementPreview {
TimelineItemTextView( TimelineItemTextView(
content = content, content = content,
interactionSource = remember { MutableInteractionSource() },
extraPadding = ExtraPadding(nbChars = 8), extraPadding = ExtraPadding(nbChars = 8),
onTextClicked = {}, onLinkClicked = {},
onTextLongClicked = {},
) )
} }

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.html
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
open class DocumentProvider : PreviewParameterProvider<Document> {
override val values: Sequence<Document>
get() = sequenceOf(
"text",
"<strong>Strong</strong>",
"<b>Bold</b>",
"<i>Italic</i>",
// FIXME This does not work
"<b><i>Bold then italic</i></b>",
// FIXME This does not work
"<i><b>Italic then bold</b></i>",
"<em>em</em>",
"<unknown>unknown</unknown>",
// FIXME `br` is not rendered correctly in the Preview.
"Line 1<br/>Line 2",
"<code>code</code>",
"<del>del</del>",
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6><h7>Heading 7</h7>",
"<a href=\"https://matrix.org\">link</a>",
"<p>paragraph</p>",
"<p>paragraph 1</p><p>paragraph 2</p>",
"<ol><li>ol item 1</li><li>ol item 2</li></ol>",
"<ol><li><i>ol item 1 italic</i></li><li><b>ol item 2 bold</b></li></ol>",
"<ul><li>ul item 1</li><li>ul item 2</li></ul>",
"<blockquote>blockquote</blockquote>",
// TODO Find a way to make is work with `pre`. For now there is an error with
// jsoup: java.lang.NoSuchMethodError: 'org.jsoup.nodes.Element org.jsoup.nodes.Element.firstElementChild()'
// "<pre>pre</pre>",
"<mx-reply><blockquote><a href=\\\"https://matrix.to/#/!roomId/\$eventId?via=matrix.org\\\">In reply to</a> " +
"<a href=\\\"https://matrix.to/#/@alice:matrix.org\\\">@alice:matrix.org</a><br>original message</blockquote></mx-reply>reply",
"<ol><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ol>",
"<ul><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ul>",
).map { Jsoup.parse(it) }
}

View file

@ -1,635 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalLayoutApi::class)
package io.element.android.features.messages.impl.timeline.components.html
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
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.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
import io.element.android.features.messages.impl.timeline.components.event.getDpSize
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val CHIP_ID = "chip"
@Composable
fun HtmlDocument(
document: Document,
extraPadding: ExtraPadding,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
) {
HtmlBody(
body = document.body(),
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
Spacer(
modifier = Modifier.size(
width = extraPadding.getDpSize(),
height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
)
)
}
}
@Composable
private fun HtmlBody(
body: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
@Composable
fun NodesFlowRode(
nodes: Iterator<Node>,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
) = FlowRow(
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.Start),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
) {
var sameRow = true
while (sameRow && nodes.hasNext()) {
when (val node = nodes.next()) {
is TextNode -> {
if (!node.isBlank) {
ClickableLinkText(
text = node.text(),
interactionSource = interactionSource,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
)
}
}
is Element -> {
if (node.isInline()) {
HtmlInline(
node,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
} else {
HtmlBlock(
element = node,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
sameRow = false
}
}
else -> continue
}
}
}
Column(modifier = modifier) {
val nodesIterator = body.childNodes().iterator()
while (nodesIterator.hasNext()) {
NodesFlowRode(
nodes = nodesIterator,
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
}
}
}
private fun Element.isInline(): Boolean {
return when (tagName().lowercase()) {
"del" -> true
"mx-reply" -> false
else -> !isBlock
}
}
@Composable
private fun HtmlBlock(
element: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val blockModifier = modifier
.padding(top = 4.dp)
when (element.tagName().lowercase()) {
"p" -> HtmlParagraph(
paragraph = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(
heading = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"ol" -> HtmlOrderedList(
orderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"ul" -> HtmlUnorderedList(
unorderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"blockquote" -> HtmlBlockquote(
blockquote = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(
mxReply = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
else -> return
}
}
@Composable
private fun HtmlInline(
element: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineElement(element, MaterialTheme.colorScheme)
}
HtmlText(
text = styledText,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlPreformatted(
pre: Element,
modifier: Modifier = Modifier
) {
val isCode = pre.firstElementChild()?.tagName()?.lowercase() == "code"
val backgroundColor =
if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified
Box(
modifier
.background(color = backgroundColor)
.padding(horizontal = 8.dp)
) {
Text(
text = pre.wholeText(),
style = TextStyle(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun HtmlParagraph(
paragraph: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = styledText, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlBlockquote(
blockquote: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val color = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
.drawBehind {
drawLine(
color = color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height)
)
}
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp)
) {
val text = buildAnnotatedString {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
}
}
HtmlText(
text = text, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlHeading(
heading: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val style = when (heading.tagName().lowercase()) {
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
"h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp)
"h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp)
"h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp)
"h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp)
else -> {
return
}
}
Box(modifier) {
val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = text,
style = style,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlMxReply(
mxReply: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val blockquote = mxReply.childNodes().firstOrNull() ?: return
val shape = RoundedCornerShape(12.dp)
Surface(
modifier = modifier
.padding(bottom = 4.dp)
.offset(x = (-8).dp),
color = MaterialTheme.colorScheme.background,
shape = shape,
) {
val text = buildAnnotatedString {
for (blockquoteNode in blockquote.childNodes()) {
when (blockquoteNode) {
is TextNode -> {
withStyle(
style = SpanStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary
)
) {
append(blockquoteNode.text())
}
}
is Element -> {
when (blockquoteNode.tagName().lowercase()) {
"br" -> {
append('\n')
}
"a" -> {
append(blockquoteNode.ownText())
}
}
}
}
}
}
HtmlText(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlOrderedList(
orderedList: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val delimiter = "."
HtmlListItems(
list = orderedList,
marker = { index -> "$index$delimiter" },
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
@Composable
private fun HtmlUnorderedList(
unorderedList: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val marker = ""
HtmlListItems(
list = unorderedList,
marker = { marker },
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
@Composable
private fun HtmlListItems(
list: Element,
marker: (Int) -> String,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
for ((index, node) in list.children().withIndex()) {
val areAllChildrenInline = node.childNodes().all { it is TextNode || it is Element && it.isInline() }
if (areAllChildrenInline) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
appendInlineChildrenElements(node.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = text,
interactionSource = remember { MutableInteractionSource() },
onClick = onTextClicked,
onLongClick = onTextLongClicked,
)
} else {
for (innerNode in node.childNodes()) {
when (innerNode) {
is TextNode -> {
if (!innerNode.isBlank) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
}
HtmlText(
text = text,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
is Element -> HtmlBlock(
element = innerNode,
modifier = Modifier.padding(start = 4.dp),
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
}
}
}
}
}
private fun ColorScheme.codeBackground(): Color {
return background.copy(alpha = 0.3f)
}
private fun AnnotatedString.Builder.appendInlineChildrenElements(
childNodes: List<Node>,
colors: ColorScheme
) {
for (node in childNodes) {
when (node) {
is TextNode -> {
append(node.text())
}
is Element -> {
appendInlineElement(node, colors)
}
}
}
}
private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) {
when (element.tagName().lowercase()) {
"br" -> {
append('\n')
}
"code" -> {
withStyle(
style = TextStyle(
fontFamily = FontFamily.Monospace,
background = colors.codeBackground()
).toSpanStyle()
) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"del" -> {
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"i",
"em" -> {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"strong" -> {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"a" -> {
appendLink(element)
}
else -> {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
}
private fun AnnotatedString.Builder.appendLink(link: Element) {
val uriString = link.attr("href")
val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) {
is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString())
withStyle(
style = SpanStyle(color = LinkColor)
) {
append(link.ownText())
}
pop()
}
is PermalinkData.RoomEmailInviteLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
is PermalinkData.RoomLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
is PermalinkData.UserLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
}
}
fun AnnotatedString.Builder.safeAppendInlineContent(chipId: String, ownText: String) {
if (ownText.isEmpty()) {
// alternateText cannot be empty and default parameter value is private,
// so just omit the second param here.
appendInlineContent(chipId)
} else {
appendInlineContent(chipId, ownText)
}
}
@Composable
private fun HtmlText(
text: AnnotatedString,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
) {
val inlineContentMap = persistentMapOf<String, InlineTextContent>()
ClickableLinkText(
annotatedString = text,
style = style,
modifier = modifier,
inlineContent = inlineContentMap,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick
)
}
@PreviewsDayNight
@Composable
internal fun HtmlDocumentPreview(@PreviewParameter(DocumentProvider::class) document: Document) = ElementPreview {
HtmlDocument(
document = document,
extraPadding = noExtraPadding,
interactionSource = remember { MutableInteractionSource() },
onTextClicked = {},
onTextLongClicked = {},
)
}

View file

@ -16,7 +16,14 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import android.text.Spannable
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.util.LinkifyCompat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -35,9 +42,11 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@ -52,8 +61,9 @@ import kotlin.time.Duration
class TimelineItemContentMessageFactory @Inject constructor( class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter, private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor, private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
) { ) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent { suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is EmoteMessageType -> TimelineItemEmoteContent( is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}", body = "* $senderDisplayName ${messageType.body}",
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"), htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName"),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
is ImageMessageType -> { is ImageMessageType -> {
@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
body = messageType.body, body = messageType.body,
htmlDocument = null, htmlDocument = null,
plainText = messageType.body, plainText = messageType.body,
formattedBody = null,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} else { } else {
@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
is NoticeMessageType -> TimelineItemNoticeContent( is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body, body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
is TextMessageType -> { is TextMessageType -> {
TimelineItemTextContent( TimelineItemTextContent(
body = messageType.body, body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
is OtherMessageType -> TimelineItemTextContent( is OtherMessageType -> TimelineItemTextContent(
body = messageType.body, body = messageType.body,
htmlDocument = null, htmlDocument = null,
formattedBody = null,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
return result?.takeIf { it.isFinite() } return result?.takeIf { it.isFinite() }
} }
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body)
.withFixedURLSpans()
return if (prefix != null) {
buildSpannedString {
append(prefix)
append(" ")
append(result)
}
} else {
result
}
}
private fun CharSequence.withFixedURLSpans(): CharSequence {
if (this !is Spannable) return this
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
val oldURLSpans = getSpans<URLSpan>(0, length).associateWith {
val start = getSpanStart(it)
val end = getSpanEnd(it)
Pair(start, end)
}
// Find and set as URLSpans any links present in the text
LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS)
// Restore old spans if they don't conflict with the new ones
for ((urlSpan, location) in oldURLSpans) {
val (start, end) = location
if (getSpans<URLSpan>(start, end).isEmpty()) {
setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
return this
}
} }

View file

@ -23,6 +23,7 @@ data class TimelineItemEmoteContent(
override val body: String, override val body: String,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent" override val type: String = "TimelineItemEmoteContent"

View file

@ -16,9 +16,12 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import org.jsoup.Jsoup
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> { class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf( override val values = sequenceOf(
@ -43,19 +46,29 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
} }
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> { class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
private fun buildSpanned(text: String) = buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append("Rich Text")
}
append(" ")
append(text)
}
override val values = sequenceOf( override val values = sequenceOf(
aTimelineItemEmoteContent(), aTimelineItemEmoteContent(),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")), aTimelineItemEmoteContent().copy(formattedBody = buildSpanned("Emote")),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")), aTimelineItemNoticeContent().copy(formattedBody = buildSpanned("Notice")),
aTimelineItemTextContent(), aTimelineItemTextContent(),
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")), aTimelineItemTextContent().copy(formattedBody = buildSpanned("Text")),
) )
} }
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent( fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
body = "Emote", body = "Emote",
htmlDocument = null, htmlDocument = null,
formattedBody = null,
isEdited = false, isEdited = false,
) )
@ -66,6 +79,7 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent( fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
body = "Notice", body = "Notice",
htmlDocument = null, htmlDocument = null,
formattedBody = null,
isEdited = false, isEdited = false,
) )
@ -74,6 +88,7 @@ fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
fun aTimelineItemTextContent() = TimelineItemTextContent( fun aTimelineItemTextContent() = TimelineItemTextContent(
body = "Text", body = "Text",
htmlDocument = null, htmlDocument = null,
formattedBody = null,
isEdited = false, isEdited = false,
) )

View file

@ -23,6 +23,7 @@ data class TimelineItemNoticeContent(
override val body: String, override val body: String,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent { ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent" override val type: String = "TimelineItemNoticeContent"

View file

@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent { sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String val body: String
val htmlDocument: Document? val htmlDocument: Document?
val formattedBody: CharSequence?
val plainText: String val plainText: String
val isEdited: Boolean val isEdited: Boolean
val htmlBody: String? val htmlBody: String?

View file

@ -23,7 +23,8 @@ data class TimelineItemTextContent(
override val body: String, override val body: String,
override val htmlDocument: Document?, override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body, override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemTextBasedContent{ ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent" override val type: String = "TimelineItemTextContent"
} }

View file

@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -742,6 +743,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder, currentSessionIdHolder = currentSessionIdHolder,
htmlConverterProvider = FakeHtmlConverterProvider(),
) )
} }
} }

View file

@ -113,7 +113,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -145,7 +145,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -176,7 +176,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
val successState = awaitItem() val successState = awaitItem()
@ -207,7 +207,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -328,7 +328,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -360,7 +360,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
val redactedEvent = aMessageEvent( val redactedEvent = aMessageEvent(
isMine = true, isMine = true,
@ -388,7 +388,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
eventId = null, // No event id, so it's not sent yet eventId = null, // No event id, so it's not sent yet
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))

View file

@ -39,7 +39,7 @@ internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true, isMine: Boolean = true,
isEditable: Boolean = true, isEditable: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -54,6 +55,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
), ),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(), stickerFactory = TimelineItemContentStickerFactory(),

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultHtmlConverterProviderTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider()
val exception = runCatching { provider.provide() }.exceptionOrNull()
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider()
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID)
}
}
val htmlConverter = runCatching { provider.provide() }.getOrNull()
assertThat(htmlConverter).isNotNull()
}
}

View file

@ -16,6 +16,10 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import androidx.core.text.inSpans
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -27,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -42,10 +47,12 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@RunWith(RobolectricTestRunner::class)
class TimelineItemContentMessageFactoryTest { class TimelineItemContentMessageFactoryTest {
@Test @Test
@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null, htmlDocument = null,
plainText = "body", plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null, htmlDocument = null,
plainText = "body", plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null, htmlDocument = null,
plainText = "body", plainText = "body",
isEdited = false, isEdited = false,
formattedBody = null,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@Test
fun `test create TextMessageType with HTML formatted body`() = runTest {
val expected = SpannableStringBuilder().apply {
append("link to ")
inSpans(URLSpan("https://matrix.org")) {
append("https://matrix.org")
}
append(" ")
inSpans(URLSpan("https://matrix.org")) {
append("and manually added link")
}
}
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected }
)
val result = sut.create(
content = createMessageContent(type = TextMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
}
@Test
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { it }
)
val result = sut.create(
content = createMessageContent(type = TextMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
}
@Test @Test
fun `test create VideoMessageType`() = runTest { fun `test create VideoMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest {
body = "body", body = "body",
htmlDocument = null, htmlDocument = null,
plainText = "body", plainText = "body",
formattedBody = null,
isEdited = false, isEdited = false,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@Test
fun `test create NoticeMessageType with HTML formatted body`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = NoticeMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
}
@Test @Test
fun `test create EmoteMessageType`() = runTest { fun `test create EmoteMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest {
body = "* Bob body", body = "* Bob body",
htmlDocument = null, htmlDocument = null,
plainText = "* Bob body", plainText = "* Bob body",
formattedBody = null,
isEdited = false, isEdited = false,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@Test
fun `test create EmoteMessageType with HTML formatted body`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = EmoteMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
}
private fun createMessageContent( private fun createMessageContent(
body: String = "Body", body: String = "Body",
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest {
} }
private fun createTimelineItemContentMessageFactory( private fun createTimelineItemContentMessageFactory(
featureFlagService: FeatureFlagService = FakeFeatureFlagService() featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
htmlConverterTransform: (String) -> CharSequence = { it },
) = TimelineItemContentMessageFactory( ) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
) )
} }

View file

@ -15,7 +15,7 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
@ -24,4 +24,5 @@ android {
dependencies { dependencies {
api(projects.features.messages.api) api(projects.features.messages.api)
implementation(projects.libraries.matrix.api)
} }

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
): HtmlConverterProvider {
@Composable
override fun Update(currentUserId: UserId) = Unit
override fun provide(): HtmlConverter {
return object : HtmlConverter {
override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html)
}
}
}
}

View file

@ -96,6 +96,7 @@ androidx_compose_ui = { module = "androidx.compose.ui:ui" }
androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" }
androidx_compose_material = { module = "androidx.compose.material:material" } androidx_compose_material = { module = "androidx.compose.material:material" }
androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" } androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" }

View file

@ -16,32 +16,52 @@
package io.element.android.libraries.textcomposer package io.element.android.libraries.textcomposer
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.wysiwyg.compose.RichTextEditorDefaults import io.element.android.wysiwyg.compose.RichTextEditorDefaults
import io.element.android.wysiwyg.compose.RichTextEditorStyle import io.element.android.wysiwyg.compose.RichTextEditorStyle
internal object ElementRichTextEditorStyle { object ElementRichTextEditorStyle {
@Composable @Composable
fun create( fun composerStyle(
hasFocus: Boolean, hasFocus: Boolean,
) : RichTextEditorStyle { ) : RichTextEditorStyle {
val baseStyle = common()
return baseStyle.copy(
text = baseStyle.text.copy(
color = if (hasFocus) {
ElementTheme.materialColors.primary
} else {
ElementTheme.materialColors.secondary
},
lineHeight = TextUnit.Unspecified,
includeFontPadding = true,
)
)
}
@Composable
fun textStyle(): RichTextEditorStyle {
return common()
}
@Composable
private fun common(): RichTextEditorStyle {
val colors = ElementTheme.colors val colors = ElementTheme.colors
val m3colors = MaterialTheme.colorScheme
val codeCornerRadius = 4.dp val codeCornerRadius = 4.dp
val codeBorderWidth = 1.dp val codeBorderWidth = 1.dp
return RichTextEditorDefaults.style( return RichTextEditorDefaults.style(
text = RichTextEditorDefaults.textStyle( text = RichTextEditorDefaults.textStyle(
color = if (hasFocus) { color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
m3colors.primary fontStyle = LocalTextStyle.current.fontStyle,
} else { lineHeight = LocalTextStyle.current.lineHeight,
m3colors.secondary includeFontPadding = false,
},
lineHeight = 16.25.sp,
), ),
cursor = RichTextEditorDefaults.cursorStyle( cursor = RichTextEditorDefaults.cursorStyle(
color = colors.iconAccentTertiary, color = colors.iconAccentTertiary,

View file

@ -441,9 +441,7 @@ private fun TextInput(
modifier = Modifier modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp) .padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(), .fillMaxWidth(),
style = ElementRichTextEditorStyle.create( style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
hasFocus = state.hasFocus
),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay,
onError = onError onError = onError

View file

@ -23,20 +23,59 @@ import android.text.style.ReplacementSpan
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MentionSpan( class MentionSpan(
val type: Type,
val backgroundColor: Int, val backgroundColor: Int,
val textColor: Int, val textColor: Int,
) : ReplacementSpan() { ) : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
return paint.measureText(text, start, end).roundToInt() + 40 val mentionText = getActualText(text, start)
var actualEnd = end
if (mentionText != text.toString()) {
actualEnd = end + 1
}
return paint.measureText(mentionText, start, actualEnd).roundToInt() + 40
} }
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val textSize = paint.measureText(text, start, end) val mentionText = getActualText(text, start)
val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat()) var actualEnd = end
if (mentionText != text.toString()) {
actualEnd = end + 1
}
val textWidth = paint.measureText(mentionText, start, actualEnd)
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
val rect = RectF(x, top.toFloat(), x + textWidth + 40, y.toFloat() + extraVerticalSpace)
paint.color = backgroundColor paint.color = backgroundColor
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint) canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
paint.color = textColor paint.color = textColor
canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint) canvas.drawText(mentionText, start, actualEnd, x + 20, y.toFloat(), paint)
}
private fun getActualText(text: CharSequence?, start: Int): String {
return when (type) {
Type.USER -> {
val mentionText = text.toString()
if (start in mentionText.indices && mentionText[start] != '@') {
mentionText.replaceRange(start, start, "@")
} else {
mentionText
}
}
Type.ROOM -> {
val mentionText = text.toString()
if (start in mentionText.indices && mentionText[start] != '#') {
mentionText.replaceRange(start, start, "#")
} else {
mentionText
}
}
}
}
enum class Type {
USER,
ROOM,
} }
} }

View file

@ -60,18 +60,21 @@ class MentionSpanProvider(
permalinkData is PermalinkData.UserLink -> { permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId == currentSessionId.value val isCurrentUser = permalinkData.userId == currentSessionId.value
MentionSpan( MentionSpan(
type = MentionSpan.Type.USER,
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor, backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor, textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
) )
} }
text == "@room" && permalinkData is PermalinkData.FallbackLink -> { text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
MentionSpan( MentionSpan(
type = MentionSpan.Type.USER,
backgroundColor = otherBackgroundColor, backgroundColor = otherBackgroundColor,
textColor = otherTextColor, textColor = otherTextColor,
) )
} }
else -> { else -> {
MentionSpan( MentionSpan(
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor, backgroundColor = otherBackgroundColor,
textColor = otherTextColor, textColor = otherTextColor,
) )
@ -97,17 +100,26 @@ internal fun MentionSpanPreview() {
provider.setup() provider.setup()
val textColor = ElementTheme.colors.textPrimary.toArgb() val textColor = ElementTheme.colors.textPrimary.toArgb()
val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org") fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org") fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
AndroidView(factory = { context -> AndroidView(factory = { context ->
TextView(context).apply { TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString { text = buildSpannedString {
append("This is a ") append("This is a ")
append("@mention", mentionSpan, 0) append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ") append(" to the current user and this is a ")
append("@mention", mentionSpan2, 0) append("@mention", mentionSpanOther(), 0)
append(" to other user") append(" to other user. This one is for a room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
} }
setTextColor(textColor) setTextColor(textColor)
} }

View file

@ -46,14 +46,21 @@ class MentionSpanProviderTest {
@Test @Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() { fun `getting mention span for current user should return a MentionSpan with custom colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("me", "https://matrix.to/#/${currentUserId.value}") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}")
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor) assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
assertThat(mentionSpan.textColor).isEqualTo(myUserColor) assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
} }
@Test @Test
fun `getting mention span for other user should return a MentionSpan with normal colors`() { fun `getting mention span for other user should return a MentionSpan with normal colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
}
@Test
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor)
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1ea2576745ff79c9fb37a54ecf9d84c0c6849404f108bd695d8332e42f8e2082 oid sha256:7c32500fa620506fb7cbf912793e5919ae2c4da8a6acf83ecd2ad344d95ea4be
size 5916 size 5937

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0a7dbee314d26d3dceef67818374250c201b68b889e2e4a14224405e58737331 oid sha256:4256e9443e028d232420062300ee0c36c540b7430fa3e7ccea71304526532c97
size 7879 size 7770

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:afc0f5d66d83f219c220d8136041abc70776d171bc9f13e3dbe3cb39740a2136 oid sha256:5684ce9c6111eed345950e036126271bf9cabf535ecea54bf81c370e941051f5
size 6199 size 6247

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8d6d5765db221efdd927da30a10dafe6f4c5851c44539a3d90efe84601cf5adb oid sha256:edca1a2ee9211e0595c45f9fe6c4c830c27e5e4edb75d3bf9fb7b3514b8a91fc
size 8168 size 8042

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fd7b233c6973e3a048c1abe50f09d9740dd4a6da0b44b1d56a9b301d9cb1f9ec oid sha256:6279e616bd3f912654774495ee38914b15f5ab89449e49e6d03509a7f719ae9e
size 5518 size 5515

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a2cdb0b10daa1ab865334f8447fc14a30a0c79a43be5d2c94f5da8d981fa0187 oid sha256:78c1bd81fb40b7adc968e0706bc392e02848a5bac536ada5143f27efed15fb77
size 7543 size 7445

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a490c7c4b58885a06fd89f2cbc8a832e50ba58fba1483e327796a3ef5b6d0c23 oid sha256:61912b822c573d1cbb730dbdada9825d4508f04f947473044ff1e5344e113c11
size 5851 size 5846

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:57c45ab46932c22290987266d5bdfbe392c1c6301803e7176545e3b1ed74b3d7 oid sha256:af0e2591d88eb92b12d910e6efa29f1673f0702bf0ab56fa9179ad39f9168ba4
size 7549 size 7399

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b38b8ec214dae0b5e0b05c5fd2c315a73004bbc5a607440a521587ff05780120 oid sha256:f34118dc5296d9b564d932bbf0a91944e31cafbd4135331a5b13c9a3034df4b5
size 6110 size 6083

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0b6708c60d03e4a0005244a7b7773adcf4f9cfbeb54f2d96c41a80c3ad32b262 oid sha256:d060ccab47110ecf584517cf922b1cca0facc705a70ab5d29fc40354b50ac184
size 7759 size 7681

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b2ef496a01c45907932fd160d4b906bc90b929d90978466a7ae96f856c0edcb8 oid sha256:5e72e218bee1fc66cf8c1f9cf2c2ba9c5654b5738b560ea5728084996345cba4
size 5484 size 5471

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4a18f7a783f1b55aa8678e669eabb6be46f45438a3737867e171c889fda6e001 oid sha256:79a9c442b826783e3c06ea3dac28f24e836a715da27c70e47cfa2296da0df270
size 7222 size 7127

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c576c7288e434193189c23f75f92b9569c65c878c4dd9e9dc9b3a44af43f792
size 5555

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e742874a48ffbfa5322d01537fbd49cacf7e42b98b87eb76bf1459a14fb82ac2
size 6353

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b14b7052c2110b2f692472ab8b33631486ab863037106d59e39480203c6432b1
size 5375

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4108d9a249b5bb1c0c88dfe54f9df0f5f7b3a5777d6fd5e00d5fd5e41608cf09
size 21917

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c41c75f17e291e0be64551202fac5147a0174da7634c1e3645842402f61ae4d9
size 5111

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ec3da0af91751e2d2e949da2f8cb94f4805e7747ba5403afd736a393d58447f
size 7145

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d8c35c24f0b725c56fc54eb025a4c42c69cf3acbad37cc550b0b950812cb20b
size 10103

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43891e66bae441f53f576c0affae0f36c98399f9bfe1d034f0db6ae218da8e47
size 8233

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7240b9666592f3627bb2e944cc83ab31eface8139c5c98dcc6c1ad9e7ca9b8b6
size 11285

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1
size 7507

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6859208aff5071a73cd5c8f9f5fe4437abd793fb564bb4dfc4a3a590a8262a1e
size 7946

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1587014aa4cef2ade84e55ab0a9209d0f10b8706164d8e8b28933554dce24d59
size 14386

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac00bcc3150b97a628a03996ce273f68bf5c8ac9db014a7039e3510b4b307726
size 5675

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c0a959f003d8eedf2a4b9284f63ea77a608e272d0dd8e564bbbbf3686c88758
size 11572

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3a3ffdc5062cea8c3226df50b0876a3ca02045a19e05974b95e5ac83b813f39
size 11502

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f5585d6b47d201c1d3d8b1e330b6ebe2bc23811a72f4454add2f563cdeb41f6
size 6193

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05bb4680a0f8afb75b8f32292b54ccc14aac2e868bc122e110c07d4dc2b30915
size 8596

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3e3588c936172c143fb195a9113d293858c8bd3678b24a88ca64dd9f3c2cb7a1
size 8554

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3cb4d5f28b52e87ce74ca667a330b3b706d1373e6a23c9a9f6a82be9a0460adc
size 5494

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c169da272ac92d2f9ada1eb90a3c644c54e6788141b4d0ee07c49a59972f567
size 6455

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8803bbcece24e3c1dd3eed4055fcb8b8aa1a3d3d06feb5c37813499808c7faf0
size 6481

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f17122c670bd4e9e8f419d40820d29ac75d3f7f0e5d6d1b4e819ddacfe40057
size 5922

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a6c94b36f43c47dea5d2b06b85ec2008b445ee54bffb45be812c3749e7c8fff
size 5527

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8e5ff9c6ba12aee23ad547b71666096f5a184ad94877135587d77e1ed263c99
size 6240

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f91e0c0c857b7e97a31c01a12947843eca7af664d23dec336e30d386575cd955
size 5345

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a3f45d7dffec016578932cfd230bd57a649b2e725943f81c4d157a958826abe
size 20718

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98992d574b00fd9b168541d7826d2290ffb5598ed31aa540ef86eb05b8cad6a0
size 5115

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12cb95c208d0c536200e75e72476f47bd584a039f47a11be86de607cfdca24ff
size 6836

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22011955b67135850138578a5d0c0211541ec1cb2d422487d149c05d2da5537b
size 9513

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43e01f8600b71c4b7d3437a89331cfa2723049b10500b74902b17e8bae82ecaa
size 7984

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9e94e9e5b42dc50ef2958872a2307bb5411e25ab8bf3e696fcc1948d04a602a
size 10588

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e3e6f73a4ca3e5630d498541e6ba4b62505652e35f8368bda9dbc5cce618456
size 7367

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:517d6b30065d903215ab57446f3639c3876d85b85bff4679ffae4c2132775dda
size 7567

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:945db7d76ce34a2285e8d083f8c9651204af59aa800ffda4587f854676f717b7
size 13529

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31d2f62b114e1b41d3c7683b2c08e7d70931139e87cf76341d03fb488add4838
size 5662

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51155f13d3795198a301788288351c386e4d509e6ebd3ed103e743762e54a426
size 11208

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6199ff6234486bfe7ed87438298086e09e51dcbe4b30a71fa494764f5b3db14c
size 11142

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d1dd0872ea0786c5b9c037f05aef2c43d278e450558cb2f2245d76a8051f4836
size 6089

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63953e0cca146083474b69eef7ac861391e111c319ca027e8222dc28cdab7032
size 8075

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5d4c973178bf5793e1231abdc1837aecd16bdb227d0b5128a36db8e1d36290c
size 8009

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa2431a475eecdf0980bd7bf237e30d8b2e1b98170f42474d8297286386342c0
size 5433

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0179e7d8b68b2bb130e0c6c8db6adde979c724d293918822e6807e9eef627135
size 6300

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:506c86795d7940d0cddef77d73eb225b31c9204fc19b81078d95eab71f6d9499
size 6301

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc0d2daf9d4bda4005f01c604bdc6688673e9a50d5e92ca1668c481327529282
size 5831

Some files were not shown because too many files have changed in this diff Show more