Merge branch 'develop' into julioromano/poll_history_entry_point
This commit is contained in:
commit
5ac3f273ea
257 changed files with 916 additions and 1161 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
|||
maxSelections = 1u,
|
||||
answers = persistentListOf(),
|
||||
votes = persistentMapOf(),
|
||||
endTime = null
|
||||
endTime = null,
|
||||
isEdited = false,
|
||||
),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
|
|
|
|||
|
|
@ -80,10 +80,8 @@ fun TimelineItemStateEventRow(
|
|||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onLinkClicked = {},
|
||||
extraPadding = noExtraPadding,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
eventSink = eventSink,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TimelineItemContentPollFactory @Inject constructor(
|
|||
answerItems = pollContentState.answerItems,
|
||||
pollKind = pollContentState.pollKind,
|
||||
isEnded = pollContentState.isPollEnded,
|
||||
isEdited = content.isEdited
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue