Merge branch 'develop' into julioromano/poll_history_entry_point

This commit is contained in:
ganfra 2023-12-14 15:17:13 +01:00
commit 5ac3f273ea
257 changed files with 916 additions and 1161 deletions

View file

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

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
@ -335,7 +336,7 @@ class MessageComposerPresenter @Inject constructor(
add(Mention.AtRoom)
}
for (userId in state.userIds) {
add(Mention.User(userId))
add(Mention.User(UserId(userId)))
}
}
}.orEmpty()

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

@ -36,8 +36,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -55,7 +54,7 @@ fun TimelineEventTimestampView(
) {
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val isMessageEdited = event.content.isEdited()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) {
Modifier.combinedClickable(

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
@ -60,8 +62,8 @@ import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
@ -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.aTimelineItemTextContent
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.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -93,6 +96,9 @@ import io.element.android.libraries.designsystem.theme.components.Text
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.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.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@ -132,6 +138,13 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
fun onMentionClicked(mention: Mention) {
when (mention) {
is Mention.User -> onUserDataClick(mention.userId)
else -> Unit // TODO implement actions for other mentions being clicked
}
}
Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
@ -176,6 +189,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked,
eventSink = eventSink,
)
}
@ -194,6 +208,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked,
eventSink = eventSink,
)
}
@ -248,6 +263,7 @@ private fun TimelineItemEventRowContent(
onReactionClicked: (emoji: String) -> Unit,
onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
onMentionClicked: (Mention) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier,
) {
@ -305,13 +321,12 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
},
onMentionClicked = onMentionClicked,
eventSink = eventSink,
)
}
@ -380,11 +395,10 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@SuppressLint("ModifierParameter")
@Suppress("ModifierNaming")
@ -473,6 +487,7 @@ private fun MessageEventBubbleContent(
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier
val contentModifier: Modifier
when {
@ -506,10 +521,21 @@ private fun MessageEventBubbleContent(
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onLinkClicked = { url ->
when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
onMentionClicked(Mention.User(UserId(permalink.userId)))
}
is PermalinkData.RoomLink -> {
onMentionClicked(Mention.Room(permalink.getRoomId(), permalink.getRoomAlias()))
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
context.openUrlInExternalApp(url)
}
}
},
extraPadding = event.toExtraPadding(),
onClick = onMessageClick,
onLongClick = onMessageLongClick,
eventSink = eventSink,
modifier = contentModifier,
)

View file

@ -131,7 +131,8 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
maxSelections = 1u,
answers = persistentListOf(),
votes = persistentMapOf(),
endTime = null
endTime = null,
isEdited = false,
),
).map {
aInReplyToDetails(

View file

@ -80,10 +80,8 @@ fun TimelineItemStateEventRow(
) {
TimelineItemEventContentView(
content = event.content,
interactionSource = interactionSource,
onLinkClicked = {},
extraPadding = noExtraPadding,
onClick = onClick,
onLongClick = onLongClick,
eventSink = eventSink,
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.compound.theme.ElementTheme
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.
// Compute the size of the worst case.
@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
fun ExtraPadding.getStr(fontSize: TextUnit): String {
if (nbChars == 0) return ""
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
return " " + "\u00A0".repeat(nbOfSpaces)
}

View file

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

View file

@ -16,56 +16,52 @@
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.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import androidx.core.text.buildSpannedString
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.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.text.toAnnotatedString
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
onLinkClicked: (String) -> Unit,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
HtmlDocument(
document = htmlDocument,
extraPadding = extraPadding,
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
} else {
Box(modifier) {
val textWithPadding = remember(content.body) {
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val fontSize = LocalTextStyle.current.fontSize
val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body)
Box(modifier) {
val textWithPadding = remember(body, fontSize) {
buildSpannedString {
append(body)
append(extraPadding.getStr(fontSize))
}
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 {
TimelineItemTextView(
content = content,
interactionSource = remember { MutableInteractionSource() },
extraPadding = ExtraPadding(nbChars = 8),
onTextClicked = {},
onTextLongClicked = {},
onLinkClicked = {},
)
}

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
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.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.TimelineItemEmoteContent
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.EmoteMessageType
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.LocationMessageType
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.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@ -52,8 +61,9 @@ import kotlin.time.Duration
class TimelineItemContentMessageFactory @Inject constructor(
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 htmlConverterProvider: HtmlConverterProvider,
) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}",
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName"),
isEdited = content.isEdited,
)
is ImageMessageType -> {
@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
body = messageType.body,
htmlDocument = null,
plainText = messageType.body,
formattedBody = null,
isEdited = content.isEdited,
)
} else {
@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited,
)
is TextMessageType -> {
TimelineItemTextContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited,
)
}
is OtherMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = null,
formattedBody = null,
isEdited = content.isEdited,
)
}
@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
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

@ -45,6 +45,7 @@ class TimelineItemContentPollFactory @Inject constructor(
answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,
isEnded = pollContentState.isPollEnded,
isEdited = content.isEdited
)
}
}

View file

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

View file

@ -63,3 +63,13 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
}
/**
* Whether the event content has been edited.
*/
fun TimelineItemEventContent.isEdited(): Boolean =
when (this) {
is TimelineItemTextBasedContent -> isEdited
is TimelineItemPollContent -> isEdited
else -> false
}

View file

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

View file

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

View file

@ -28,6 +28,7 @@ data class TimelineItemPollContent(
val answerItems: List<PollAnswerItem>,
val pollKind: PollKind,
val isEnded: Boolean,
val isEdited: Boolean
) : TimelineItemEventContent {
override val type: String = "TimelineItemPollContent"
}

View file

@ -39,6 +39,7 @@ fun aTimelineItemPollContent(
isMine: Boolean = false,
isEditable: Boolean = false,
isEnded: Boolean = false,
isEdited: Boolean = false,
): TimelineItemPollContent {
return TimelineItemPollContent(
eventId = EventId("\$anEventId"),
@ -48,5 +49,6 @@ fun aTimelineItemPollContent(
isMine = isMine,
isEditable = isEditable,
isEnded = isEnded,
isEdited = isEdited,
)
}

View file

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

View file

@ -23,7 +23,8 @@ data class TimelineItemTextContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent{
) : TimelineItemTextBasedContent {
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.timeline.FakeRedactedVoiceMessageManager
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.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
@ -745,6 +746,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder,
htmlConverterProvider = FakeHtmlConverterProvider(),
)
}
}

View file

@ -113,7 +113,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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))
// val loadingState = awaitItem()
@ -145,7 +145,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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))
// val loadingState = awaitItem()
@ -176,7 +176,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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))
val successState = awaitItem()
@ -207,7 +207,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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))
// val loadingState = awaitItem()
@ -328,7 +328,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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))
// val loadingState = awaitItem()
@ -360,7 +360,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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(
isMine = true,
@ -388,7 +388,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
eventId = null, // No event id, so it's not sent yet
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))

View file

@ -39,7 +39,7 @@ internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: 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,
isThreaded: Boolean = false,
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.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
@ -55,6 +56,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),

View file

@ -862,7 +862,7 @@ class MessageComposerPresenterTest {
advanceUntilIdle()
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID.value)))
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID)))
// Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
@ -877,7 +877,7 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
advanceUntilIdle()
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2.value)))
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2)))
// Check intentional mentions on edit message
skipItems(1)
@ -893,7 +893,7 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
advanceUntilIdle()
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3.value)))
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3)))
skipItems(1)
}

View file

@ -0,0 +1,56 @@
/*
* 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
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 io.element.android.features.location.api.Location
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.TimelineItemVideoContent
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.core.mimetype.MimeTypes
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.EmoteMessageType
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.InReplyTo
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.MessageFormat
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.OtherMessageType
@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@RunWith(RobolectricTestRunner::class)
class TimelineItemContentMessageFactoryTest {
@Test
@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
assertThat(result).isEqualTo(expected)
}
@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
assertThat(result).isEqualTo(expected)
}
@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
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
fun `test create VideoMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
htmlDocument = null,
plainText = "body",
formattedBody = null,
isEdited = false,
)
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
fun `test create EmoteMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest {
body = "* Bob body",
htmlDocument = null,
plainText = "* Bob body",
formattedBody = null,
isEdited = false,
)
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(
body: String = "Body",
inReplyTo: InReplyTo? = null,
@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest {
}
private fun createTimelineItemContentMessageFactory(
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
htmlConverterTransform: (String) -> CharSequence = { it },
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
)
}