Use RTE TextView for timeline text messages, add mention pills to messages (#1990)
* Add `formattedBody` to `TimelineItemTextBasedContent`. This is pre-computed when timeline events are being mapped from the Rust SDK. * Update `HtmlConverterProvider` styles. * Improve `MentionSpan` to add missing `@` or `#` if needed * Replace `HtmlDocument` with the `TextView` based component * Improve extra padding calculation for timestamp by rounding the float offset result instead of truncating it. * Remove composer line height workaround * Use `ElementRichTextEditorStyle` instead of `RichTextEditorDefaults` for the theming * Use slightly different styles for composer and messages (top/bottom line height discrepancies, mostly). * Add `formattedBody` to notice and emote events. --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
5cf78b696b
commit
ab58cc2ed5
232 changed files with 754 additions and 1124 deletions
3
changelog.d/1433.feature
Normal file
3
changelog.d/1433.feature
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Use the RTE library `TextView` to render text events in the timeline.
|
||||||
|
|
||||||
|
Add support for mention pills - with no interaction yet.
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-compose-library")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.api.timeline
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||||
|
|
||||||
|
interface HtmlConverterProvider {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Update(currentUserId: UserId)
|
||||||
|
|
||||||
|
fun provide(): HtmlConverter
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ dependencies {
|
||||||
implementation(libs.vanniktech.blurhash)
|
implementation(libs.vanniktech.blurhash)
|
||||||
implementation(libs.telephoto.zoomableimage)
|
implementation(libs.telephoto.zoomableimage)
|
||||||
implementation(libs.matrix.emojibase.bindings)
|
implementation(libs.matrix.emojibase.bindings)
|
||||||
|
api(libs.matrix.richtexteditor.compose)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -97,6 +98,8 @@ dependencies {
|
||||||
testImplementation(libs.test.mockk)
|
testImplementation(libs.test.mockk)
|
||||||
testImplementation(libs.test.junitext)
|
testImplementation(libs.test.junitext)
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
|
@ -107,6 +108,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val clipboardHelper: ClipboardHelper,
|
private val clipboardHelper: ClipboardHelper,
|
||||||
private val preferencesStore: PreferencesStore,
|
private val preferencesStore: PreferencesStore,
|
||||||
private val featureFlagsService: FeatureFlagService,
|
private val featureFlagsService: FeatureFlagService,
|
||||||
|
private val htmlConverterProvider: HtmlConverterProvider,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||||
|
|
@ -121,6 +123,8 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
|
htmlConverterProvider.Update(currentUserId = currentSessionIdHolder.current)
|
||||||
|
|
||||||
val roomInfo by room.roomInfoFlow.collectAsState(null)
|
val roomInfo by room.roomInfoFlow.collectAsState(null)
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val composerState = composerPresenter.present()
|
val composerState = composerPresenter.present()
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -48,6 +49,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.platform.ViewConfiguration
|
import androidx.compose.ui.platform.ViewConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -79,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.metadata
|
import io.element.android.features.messages.impl.timeline.model.metadata
|
||||||
|
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
|
@ -93,9 +96,12 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -305,8 +311,6 @@ private fun TimelineItemEventRowContent(
|
||||||
) {
|
) {
|
||||||
MessageEventBubbleContent(
|
MessageEventBubbleContent(
|
||||||
event = event,
|
event = event,
|
||||||
interactionSource = interactionSource,
|
|
||||||
onMessageClick = onClick,
|
|
||||||
onMessageLongClick = onLongClick,
|
onMessageLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClicked,
|
inReplyToClick = inReplyToClicked,
|
||||||
onTimestampClicked = {
|
onTimestampClicked = {
|
||||||
|
|
@ -380,8 +384,6 @@ private fun MessageSenderInformation(
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageEventBubbleContent(
|
private fun MessageEventBubbleContent(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
interactionSource: MutableInteractionSource,
|
|
||||||
onMessageClick: () -> Unit,
|
|
||||||
onMessageLongClick: () -> Unit,
|
onMessageLongClick: () -> Unit,
|
||||||
inReplyToClick: () -> Unit,
|
inReplyToClick: () -> Unit,
|
||||||
onTimestampClicked: () -> Unit,
|
onTimestampClicked: () -> Unit,
|
||||||
|
|
@ -473,6 +475,7 @@ private fun MessageEventBubbleContent(
|
||||||
inReplyToDetails: InReplyToDetails?,
|
inReplyToDetails: InReplyToDetails?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val timestampLayoutModifier: Modifier
|
val timestampLayoutModifier: Modifier
|
||||||
val contentModifier: Modifier
|
val contentModifier: Modifier
|
||||||
when {
|
when {
|
||||||
|
|
@ -508,9 +511,18 @@ private fun MessageEventBubbleContent(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
isMine = event.isMine,
|
isMine = event.isMine,
|
||||||
isEditable = event.isEditable,
|
isEditable = event.isEditable,
|
||||||
interactionSource = interactionSource,
|
onLinkClicked = { url ->
|
||||||
onClick = onMessageClick,
|
Timber.d("Clicked on: $url")
|
||||||
onLongClick = onMessageLongClick,
|
when (PermalinkParser.parse(Uri.parse(url))) {
|
||||||
|
is PermalinkData.UserLink -> {
|
||||||
|
// TODO open member details
|
||||||
|
}
|
||||||
|
is PermalinkData.FallbackLink -> {
|
||||||
|
context.openUrlInExternalApp(url)
|
||||||
|
}
|
||||||
|
else -> Unit // TODO handle other types of links, as room ones
|
||||||
|
}
|
||||||
|
},
|
||||||
extraPadding = event.toExtraPadding(),
|
extraPadding = event.toExtraPadding(),
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ fun TimelineItemStateEventRow(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
isMine = event.isMine,
|
isMine = event.isMine,
|
||||||
isEditable = event.isEditable,
|
isEditable = event.isEditable,
|
||||||
interactionSource = interactionSource,
|
onLinkClicked = {},
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
extraPadding = noExtraPadding,
|
extraPadding = noExtraPadding,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
modifier = Modifier.defaultTimelineContentPadding()
|
modifier = Modifier.defaultTimelineContentPadding()
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.text.toDp
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
// Allow to not overlap the timestamp with the text, in the message bubble.
|
// Allow to not overlap the timestamp with the text, in the message bubble.
|
||||||
// Compute the size of the worst case.
|
// Compute the size of the worst case.
|
||||||
|
|
@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
||||||
fun ExtraPadding.getStr(fontSize: TextUnit): String {
|
fun ExtraPadding.getStr(fontSize: TextUnit): String {
|
||||||
if (nbChars == 0) return ""
|
if (nbChars == 0) return ""
|
||||||
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
|
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
|
||||||
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1
|
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).roundToInt() + 1
|
||||||
// A space and some unbreakable spaces
|
// A space and some unbreakable spaces
|
||||||
return " " + "\u00A0".repeat(nbOfSpaces)
|
return " " + "\u00A0".repeat(nbOfSpaces)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
|
|
@ -43,10 +42,8 @@ fun TimelineItemEventContentView(
|
||||||
content: TimelineItemEventContent,
|
content: TimelineItemEventContent,
|
||||||
isMine: Boolean,
|
isMine: Boolean,
|
||||||
isEditable: Boolean,
|
isEditable: Boolean,
|
||||||
interactionSource: MutableInteractionSource,
|
|
||||||
extraPadding: ExtraPadding,
|
extraPadding: ExtraPadding,
|
||||||
onClick: () -> Unit,
|
onLinkClicked: (url: String) -> Unit,
|
||||||
onLongClick: () -> Unit,
|
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -65,10 +62,8 @@ fun TimelineItemEventContentView(
|
||||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||||
content = content,
|
content = content,
|
||||||
extraPadding = extraPadding,
|
extraPadding = extraPadding,
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onTextClicked = onClick,
|
onLinkClicked = onLinkClicked,
|
||||||
onTextLongClicked = onLongClick
|
|
||||||
)
|
)
|
||||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||||
content = content,
|
content = content,
|
||||||
|
|
|
||||||
|
|
@ -16,56 +16,52 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import android.text.SpannableString
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.core.text.buildSpannedString
|
||||||
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
|
||||||
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemTextView(
|
fun TimelineItemTextView(
|
||||||
content: TimelineItemTextBasedContent,
|
content: TimelineItemTextBasedContent,
|
||||||
interactionSource: MutableInteractionSource,
|
|
||||||
extraPadding: ExtraPadding,
|
extraPadding: ExtraPadding,
|
||||||
onTextClicked: () -> Unit,
|
onLinkClicked: (String) -> Unit,
|
||||||
onTextLongClicked: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
|
CompositionLocalProvider(
|
||||||
val htmlDocument = content.htmlDocument
|
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||||
if (htmlDocument != null) {
|
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||||
HtmlDocument(
|
) {
|
||||||
document = htmlDocument,
|
val fontSize = LocalTextStyle.current.fontSize
|
||||||
extraPadding = extraPadding,
|
|
||||||
modifier = modifier,
|
val formattedBody = content.formattedBody
|
||||||
onTextClicked = onTextClicked,
|
val body = SpannableString(formattedBody ?: content.body)
|
||||||
onTextLongClicked = onTextLongClicked,
|
|
||||||
interactionSource = interactionSource
|
Box(modifier) {
|
||||||
)
|
val textWithPadding = remember(body, fontSize) {
|
||||||
} else {
|
buildSpannedString {
|
||||||
Box(modifier) {
|
append(body)
|
||||||
val textWithPadding = remember(content.body) {
|
append(extraPadding.getStr(fontSize))
|
||||||
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
|
|
||||||
}
|
}
|
||||||
ClickableLinkText(
|
|
||||||
text = textWithPadding,
|
|
||||||
onClick = onTextClicked,
|
|
||||||
onLongClick = onTextLongClicked,
|
|
||||||
interactionSource = interactionSource
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
EditorStyledText(
|
||||||
|
text = textWithPadding,
|
||||||
|
onLinkClickedListener = onLinkClicked,
|
||||||
|
style = ElementRichTextEditorStyle.textStyle(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,9 +73,7 @@ internal fun TimelineItemTextViewPreview(
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
TimelineItemTextView(
|
TimelineItemTextView(
|
||||||
content = content,
|
content = content,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
extraPadding = ExtraPadding(nbChars = 8),
|
extraPadding = ExtraPadding(nbChars = 8),
|
||||||
onTextClicked = {},
|
onLinkClicked = {},
|
||||||
onTextLongClicked = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
|
import android.text.Spannable
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import android.text.util.Linkify
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.getSpans
|
||||||
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
|
@ -35,9 +42,11 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
|
|
@ -52,8 +61,9 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
class TimelineItemContentMessageFactory @Inject constructor(
|
class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
private val fileSizeFormatter: FileSizeFormatter,
|
private val fileSizeFormatter: FileSizeFormatter,
|
||||||
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
|
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val htmlConverterProvider: HtmlConverterProvider,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
||||||
|
|
@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
is EmoteMessageType -> TimelineItemEmoteContent(
|
is EmoteMessageType -> TimelineItemEmoteContent(
|
||||||
body = "* $senderDisplayName ${messageType.body}",
|
body = "* $senderDisplayName ${messageType.body}",
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
|
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
|
||||||
|
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName"),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
is ImageMessageType -> {
|
is ImageMessageType -> {
|
||||||
|
|
@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = messageType.body,
|
plainText = messageType.body,
|
||||||
|
formattedBody = null,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
is NoticeMessageType -> TimelineItemNoticeContent(
|
is NoticeMessageType -> TimelineItemNoticeContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||||
|
formattedBody = parseHtml(messageType.formatted),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
is TextMessageType -> {
|
is TextMessageType -> {
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||||
|
formattedBody = parseHtml(messageType.formatted),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is OtherMessageType -> TimelineItemTextContent(
|
is OtherMessageType -> TimelineItemTextContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
formattedBody = null,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
|
|
||||||
return result?.takeIf { it.isFinite() }
|
return result?.takeIf { it.isFinite() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
|
||||||
|
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
|
||||||
|
val result = htmlConverterProvider.provide()
|
||||||
|
.fromHtmlToSpans(formattedBody.body)
|
||||||
|
.withFixedURLSpans()
|
||||||
|
return if (prefix != null) {
|
||||||
|
buildSpannedString {
|
||||||
|
append(prefix)
|
||||||
|
append(" ")
|
||||||
|
append(result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.withFixedURLSpans(): CharSequence {
|
||||||
|
if (this !is Spannable) return this
|
||||||
|
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
|
||||||
|
val oldURLSpans = getSpans<URLSpan>(0, length).associateWith {
|
||||||
|
val start = getSpanStart(it)
|
||||||
|
val end = getSpanEnd(it)
|
||||||
|
Pair(start, end)
|
||||||
|
}
|
||||||
|
// Find and set as URLSpans any links present in the text
|
||||||
|
LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS)
|
||||||
|
// Restore old spans if they don't conflict with the new ones
|
||||||
|
for ((urlSpan, location) in oldURLSpans) {
|
||||||
|
val (start, end) = location
|
||||||
|
if (getSpans<URLSpan>(start, end).isEmpty()) {
|
||||||
|
setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ data class TimelineItemEmoteContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
|
override val formattedBody: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemEmoteContent"
|
override val type: String = "TimelineItemEmoteContent"
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.style.StyleSpan
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.inSpans
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
import org.jsoup.Jsoup
|
|
||||||
|
|
||||||
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
|
|
@ -43,19 +46,29 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
||||||
|
|
||||||
|
private fun buildSpanned(text: String) = buildSpannedString {
|
||||||
|
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||||
|
append("Rich Text")
|
||||||
|
}
|
||||||
|
append(" ")
|
||||||
|
append(text)
|
||||||
|
}
|
||||||
|
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
aTimelineItemEmoteContent(),
|
aTimelineItemEmoteContent(),
|
||||||
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
|
aTimelineItemEmoteContent().copy(formattedBody = buildSpanned("Emote")),
|
||||||
aTimelineItemNoticeContent(),
|
aTimelineItemNoticeContent(),
|
||||||
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
|
aTimelineItemNoticeContent().copy(formattedBody = buildSpanned("Notice")),
|
||||||
aTimelineItemTextContent(),
|
aTimelineItemTextContent(),
|
||||||
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")),
|
aTimelineItemTextContent().copy(formattedBody = buildSpanned("Text")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
|
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
|
||||||
body = "Emote",
|
body = "Emote",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
formattedBody = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,6 +79,7 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
|
||||||
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
|
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
|
||||||
body = "Notice",
|
body = "Notice",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
formattedBody = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,6 +88,7 @@ fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
|
||||||
fun aTimelineItemTextContent() = TimelineItemTextContent(
|
fun aTimelineItemTextContent() = TimelineItemTextContent(
|
||||||
body = "Text",
|
body = "Text",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
formattedBody = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ data class TimelineItemNoticeContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
|
override val formattedBody: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemNoticeContent"
|
override val type: String = "TimelineItemNoticeContent"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
|
||||||
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
|
val formattedBody: CharSequence?
|
||||||
val plainText: String
|
val plainText: String
|
||||||
val isEdited: Boolean
|
val isEdited: Boolean
|
||||||
val htmlBody: String?
|
val htmlBody: String?
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ data class TimelineItemTextContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
|
override val formattedBody: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent{
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemTextContent"
|
override val type: String = "TimelineItemTextContent"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
|
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -742,6 +743,7 @@ class MessagesPresenterTest {
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
currentSessionIdHolder = currentSessionIdHolder,
|
currentSessionIdHolder = currentSessionIdHolder,
|
||||||
|
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||||
// val loadingState = awaitItem()
|
// val loadingState = awaitItem()
|
||||||
|
|
@ -145,7 +145,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
|
||||||
// val loadingState = awaitItem()
|
// val loadingState = awaitItem()
|
||||||
|
|
@ -176,7 +176,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
|
||||||
val successState = awaitItem()
|
val successState = awaitItem()
|
||||||
|
|
@ -207,7 +207,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||||
// val loadingState = awaitItem()
|
// val loadingState = awaitItem()
|
||||||
|
|
@ -328,7 +328,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||||
// val loadingState = awaitItem()
|
// val loadingState = awaitItem()
|
||||||
|
|
@ -360,7 +360,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||||
)
|
)
|
||||||
val redactedEvent = aMessageEvent(
|
val redactedEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
|
|
@ -388,7 +388,7 @@ class ActionListPresenterTest {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
eventId = null, // No event id, so it's not sent yet
|
eventId = null, // No event id, so it's not sent yet
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
|
||||||
)
|
)
|
||||||
|
|
||||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ internal fun aMessageEvent(
|
||||||
eventId: EventId? = AN_EVENT_ID,
|
eventId: EventId? = AN_EVENT_ID,
|
||||||
isMine: Boolean = true,
|
isMine: Boolean = true,
|
||||||
isEditable: Boolean = true,
|
isEditable: Boolean = true,
|
||||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
|
||||||
inReplyTo: InReplyToDetails? = null,
|
inReplyTo: InReplyToDetails? = null,
|
||||||
isThreaded: Boolean = false,
|
isThreaded: Boolean = false,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
|
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||||
|
|
@ -54,6 +55,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
|
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||||
),
|
),
|
||||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||||
stickerFactory = TimelineItemContentStickerFactory(),
|
stickerFactory = TimelineItemContentStickerFactory(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DefaultHtmlConverterProviderTest {
|
||||||
|
|
||||||
|
@get:Rule val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calling provide without calling Update first should throw an exception`() {
|
||||||
|
val provider = DefaultHtmlConverterProvider()
|
||||||
|
|
||||||
|
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
||||||
|
|
||||||
|
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||||
|
val provider = DefaultHtmlConverterProvider()
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||||
|
provider.Update(currentUserId = A_USER_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
||||||
|
|
||||||
|
assertThat(htmlConverter).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.factories.event
|
package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import androidx.core.text.inSpans
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
|
|
@ -27,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
|
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -42,10 +47,12 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||||
|
|
@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class TimelineItemContentMessageFactoryTest {
|
class TimelineItemContentMessageFactoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
plainText = "body",
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
formattedBody = null,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
plainText = "body",
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
formattedBody = null,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
plainText = "body",
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
formattedBody = null,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test create TextMessageType with HTML formatted body`() = runTest {
|
||||||
|
val expected = SpannableStringBuilder().apply {
|
||||||
|
append("link to ")
|
||||||
|
inSpans(URLSpan("https://matrix.org")) {
|
||||||
|
append("https://matrix.org")
|
||||||
|
}
|
||||||
|
append(" ")
|
||||||
|
inSpans(URLSpan("https://matrix.org")) {
|
||||||
|
append("and manually added link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sut = createTimelineItemContentMessageFactory(
|
||||||
|
htmlConverterTransform = { expected }
|
||||||
|
)
|
||||||
|
val result = sut.create(
|
||||||
|
content = createMessageContent(type = TextMessageType(
|
||||||
|
body = "body",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
||||||
|
)),
|
||||||
|
senderDisplayName = "Bob",
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
|
||||||
|
val sut = createTimelineItemContentMessageFactory(
|
||||||
|
htmlConverterTransform = { it }
|
||||||
|
)
|
||||||
|
val result = sut.create(
|
||||||
|
content = createMessageContent(type = TextMessageType(
|
||||||
|
body = "body",
|
||||||
|
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
||||||
|
)),
|
||||||
|
senderDisplayName = "Bob",
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test create VideoMessageType`() = runTest {
|
fun `test create VideoMessageType`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
|
|
@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "body",
|
body = "body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "body",
|
plainText = "body",
|
||||||
|
formattedBody = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test create NoticeMessageType with HTML formatted body`() = runTest {
|
||||||
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
|
val result = sut.create(
|
||||||
|
content = createMessageContent(type = NoticeMessageType(
|
||||||
|
body = "body",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||||
|
)),
|
||||||
|
senderDisplayName = "Bob",
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test create EmoteMessageType`() = runTest {
|
fun `test create EmoteMessageType`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
|
|
@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
body = "* Bob body",
|
body = "* Bob body",
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
plainText = "* Bob body",
|
plainText = "* Bob body",
|
||||||
|
formattedBody = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test create EmoteMessageType with HTML formatted body`() = runTest {
|
||||||
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
|
val result = sut.create(
|
||||||
|
content = createMessageContent(type = EmoteMessageType(
|
||||||
|
body = "body",
|
||||||
|
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||||
|
)),
|
||||||
|
senderDisplayName = "Bob",
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createMessageContent(
|
private fun createMessageContent(
|
||||||
body: String = "Body",
|
body: String = "Body",
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyTo? = null,
|
||||||
|
|
@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTimelineItemContentMessageFactory(
|
private fun createTimelineItemContentMessageFactory(
|
||||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
htmlConverterTransform: (String) -> CharSequence = { it },
|
||||||
) = TimelineItemContentMessageFactory(
|
) = TimelineItemContentMessageFactory(
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
|
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-compose-library")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -24,4 +24,5 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.messages.api)
|
api(projects.features.messages.api)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.test.timeline
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||||
|
|
||||||
|
class FakeHtmlConverterProvider(
|
||||||
|
private val transform: (String) -> CharSequence = { it },
|
||||||
|
): HtmlConverterProvider {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Update(currentUserId: UserId) = Unit
|
||||||
|
|
||||||
|
override fun provide(): HtmlConverter {
|
||||||
|
return object : HtmlConverter {
|
||||||
|
override fun fromHtmlToSpans(html: String): CharSequence {
|
||||||
|
return transform(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,6 +96,7 @@ androidx_compose_ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
|
androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" }
|
||||||
androidx_compose_material = { module = "androidx.compose.material:material" }
|
androidx_compose_material = { module = "androidx.compose.material:material" }
|
||||||
androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" }
|
androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,32 +16,52 @@
|
||||||
|
|
||||||
package io.element.android.libraries.textcomposer
|
package io.element.android.libraries.textcomposer
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
|
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorStyle
|
import io.element.android.wysiwyg.compose.RichTextEditorStyle
|
||||||
|
|
||||||
internal object ElementRichTextEditorStyle {
|
object ElementRichTextEditorStyle {
|
||||||
@Composable
|
@Composable
|
||||||
fun create(
|
fun composerStyle(
|
||||||
hasFocus: Boolean,
|
hasFocus: Boolean,
|
||||||
) : RichTextEditorStyle {
|
) : RichTextEditorStyle {
|
||||||
|
val baseStyle = common()
|
||||||
|
return baseStyle.copy(
|
||||||
|
text = baseStyle.text.copy(
|
||||||
|
color = if (hasFocus) {
|
||||||
|
ElementTheme.materialColors.primary
|
||||||
|
} else {
|
||||||
|
ElementTheme.materialColors.secondary
|
||||||
|
},
|
||||||
|
lineHeight = TextUnit.Unspecified,
|
||||||
|
includeFontPadding = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun textStyle(): RichTextEditorStyle {
|
||||||
|
return common()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun common(): RichTextEditorStyle {
|
||||||
val colors = ElementTheme.colors
|
val colors = ElementTheme.colors
|
||||||
val m3colors = MaterialTheme.colorScheme
|
|
||||||
val codeCornerRadius = 4.dp
|
val codeCornerRadius = 4.dp
|
||||||
val codeBorderWidth = 1.dp
|
val codeBorderWidth = 1.dp
|
||||||
return RichTextEditorDefaults.style(
|
return RichTextEditorDefaults.style(
|
||||||
text = RichTextEditorDefaults.textStyle(
|
text = RichTextEditorDefaults.textStyle(
|
||||||
color = if (hasFocus) {
|
color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
|
||||||
m3colors.primary
|
fontStyle = LocalTextStyle.current.fontStyle,
|
||||||
} else {
|
lineHeight = LocalTextStyle.current.lineHeight,
|
||||||
m3colors.secondary
|
includeFontPadding = false,
|
||||||
},
|
|
||||||
lineHeight = 16.25.sp,
|
|
||||||
),
|
),
|
||||||
cursor = RichTextEditorDefaults.cursorStyle(
|
cursor = RichTextEditorDefaults.cursorStyle(
|
||||||
color = colors.iconAccentTertiary,
|
color = colors.iconAccentTertiary,
|
||||||
|
|
|
||||||
|
|
@ -441,9 +441,7 @@ private fun TextInput(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 6.dp, bottom = 6.dp)
|
.padding(top = 6.dp, bottom = 6.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
style = ElementRichTextEditorStyle.create(
|
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||||
hasFocus = state.hasFocus
|
|
||||||
),
|
|
||||||
resolveMentionDisplay = resolveMentionDisplay,
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||||
onError = onError
|
onError = onError
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,59 @@ import android.text.style.ReplacementSpan
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MentionSpan(
|
class MentionSpan(
|
||||||
|
val type: Type,
|
||||||
val backgroundColor: Int,
|
val backgroundColor: Int,
|
||||||
val textColor: Int,
|
val textColor: Int,
|
||||||
) : ReplacementSpan() {
|
) : ReplacementSpan() {
|
||||||
|
|
||||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
||||||
return paint.measureText(text, start, end).roundToInt() + 40
|
val mentionText = getActualText(text, start)
|
||||||
|
var actualEnd = end
|
||||||
|
if (mentionText != text.toString()) {
|
||||||
|
actualEnd = end + 1
|
||||||
|
}
|
||||||
|
return paint.measureText(mentionText, start, actualEnd).roundToInt() + 40
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
||||||
val textSize = paint.measureText(text, start, end)
|
val mentionText = getActualText(text, start)
|
||||||
val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat())
|
var actualEnd = end
|
||||||
|
if (mentionText != text.toString()) {
|
||||||
|
actualEnd = end + 1
|
||||||
|
}
|
||||||
|
val textWidth = paint.measureText(mentionText, start, actualEnd)
|
||||||
|
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
||||||
|
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
||||||
|
val rect = RectF(x, top.toFloat(), x + textWidth + 40, y.toFloat() + extraVerticalSpace)
|
||||||
paint.color = backgroundColor
|
paint.color = backgroundColor
|
||||||
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
|
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
|
||||||
paint.color = textColor
|
paint.color = textColor
|
||||||
canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint)
|
canvas.drawText(mentionText, start, actualEnd, x + 20, y.toFloat(), paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getActualText(text: CharSequence?, start: Int): String {
|
||||||
|
return when (type) {
|
||||||
|
Type.USER -> {
|
||||||
|
val mentionText = text.toString()
|
||||||
|
if (start in mentionText.indices && mentionText[start] != '@') {
|
||||||
|
mentionText.replaceRange(start, start, "@")
|
||||||
|
} else {
|
||||||
|
mentionText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Type.ROOM -> {
|
||||||
|
val mentionText = text.toString()
|
||||||
|
if (start in mentionText.indices && mentionText[start] != '#') {
|
||||||
|
mentionText.replaceRange(start, start, "#")
|
||||||
|
} else {
|
||||||
|
mentionText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
USER,
|
||||||
|
ROOM,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,21 @@ class MentionSpanProvider(
|
||||||
permalinkData is PermalinkData.UserLink -> {
|
permalinkData is PermalinkData.UserLink -> {
|
||||||
val isCurrentUser = permalinkData.userId == currentSessionId.value
|
val isCurrentUser = permalinkData.userId == currentSessionId.value
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
type = MentionSpan.Type.USER,
|
||||||
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
||||||
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
type = MentionSpan.Type.USER,
|
||||||
backgroundColor = otherBackgroundColor,
|
backgroundColor = otherBackgroundColor,
|
||||||
textColor = otherTextColor,
|
textColor = otherTextColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
type = MentionSpan.Type.ROOM,
|
||||||
backgroundColor = otherBackgroundColor,
|
backgroundColor = otherBackgroundColor,
|
||||||
textColor = otherTextColor,
|
textColor = otherTextColor,
|
||||||
)
|
)
|
||||||
|
|
@ -97,17 +100,26 @@ internal fun MentionSpanPreview() {
|
||||||
provider.setup()
|
provider.setup()
|
||||||
|
|
||||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||||
val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
||||||
val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
||||||
|
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
|
||||||
AndroidView(factory = { context ->
|
AndroidView(factory = { context ->
|
||||||
TextView(context).apply {
|
TextView(context).apply {
|
||||||
|
includeFontPadding = false
|
||||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
text = buildSpannedString {
|
text = buildSpannedString {
|
||||||
append("This is a ")
|
append("This is a ")
|
||||||
append("@mention", mentionSpan, 0)
|
append("@mention", mentionSpanMe(), 0)
|
||||||
append(" to the current user and this is a ")
|
append(" to the current user and this is a ")
|
||||||
append("@mention", mentionSpan2, 0)
|
append("@mention", mentionSpanOther(), 0)
|
||||||
append(" to other user")
|
append(" to other user. This one is for a room: ")
|
||||||
|
append("#room:matrix.org", mentionSpanRoom(), 0)
|
||||||
|
append("\n\n")
|
||||||
|
append("This ")
|
||||||
|
append("mention", mentionSpanMe(), 0)
|
||||||
|
append(" didn't have an '@' and it was automatically added, same as this ")
|
||||||
|
append("room:matrix.org", mentionSpanRoom(), 0)
|
||||||
|
append(" one, which had no leading '#'.")
|
||||||
}
|
}
|
||||||
setTextColor(textColor)
|
setTextColor(textColor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,21 @@ class MentionSpanProviderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getting mention span for current user should return a MentionSpan with custom colors`() {
|
fun `getting mention span for current user should return a MentionSpan with custom colors`() {
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("me", "https://matrix.to/#/${currentUserId.value}")
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}")
|
||||||
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
|
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
|
||||||
assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
|
assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getting mention span for other user should return a MentionSpan with normal colors`() {
|
fun `getting mention span for other user should return a MentionSpan with normal colors`() {
|
||||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org")
|
||||||
|
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
||||||
|
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
|
||||||
|
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
|
||||||
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
||||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1ea2576745ff79c9fb37a54ecf9d84c0c6849404f108bd695d8332e42f8e2082
|
oid sha256:7c32500fa620506fb7cbf912793e5919ae2c4da8a6acf83ecd2ad344d95ea4be
|
||||||
size 5916
|
size 5937
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0a7dbee314d26d3dceef67818374250c201b68b889e2e4a14224405e58737331
|
oid sha256:4256e9443e028d232420062300ee0c36c540b7430fa3e7ccea71304526532c97
|
||||||
size 7879
|
size 7770
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:afc0f5d66d83f219c220d8136041abc70776d171bc9f13e3dbe3cb39740a2136
|
oid sha256:5684ce9c6111eed345950e036126271bf9cabf535ecea54bf81c370e941051f5
|
||||||
size 6199
|
size 6247
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8d6d5765db221efdd927da30a10dafe6f4c5851c44539a3d90efe84601cf5adb
|
oid sha256:edca1a2ee9211e0595c45f9fe6c4c830c27e5e4edb75d3bf9fb7b3514b8a91fc
|
||||||
size 8168
|
size 8042
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fd7b233c6973e3a048c1abe50f09d9740dd4a6da0b44b1d56a9b301d9cb1f9ec
|
oid sha256:6279e616bd3f912654774495ee38914b15f5ab89449e49e6d03509a7f719ae9e
|
||||||
size 5518
|
size 5515
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a2cdb0b10daa1ab865334f8447fc14a30a0c79a43be5d2c94f5da8d981fa0187
|
oid sha256:78c1bd81fb40b7adc968e0706bc392e02848a5bac536ada5143f27efed15fb77
|
||||||
size 7543
|
size 7445
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a490c7c4b58885a06fd89f2cbc8a832e50ba58fba1483e327796a3ef5b6d0c23
|
oid sha256:61912b822c573d1cbb730dbdada9825d4508f04f947473044ff1e5344e113c11
|
||||||
size 5851
|
size 5846
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:57c45ab46932c22290987266d5bdfbe392c1c6301803e7176545e3b1ed74b3d7
|
oid sha256:af0e2591d88eb92b12d910e6efa29f1673f0702bf0ab56fa9179ad39f9168ba4
|
||||||
size 7549
|
size 7399
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b38b8ec214dae0b5e0b05c5fd2c315a73004bbc5a607440a521587ff05780120
|
oid sha256:f34118dc5296d9b564d932bbf0a91944e31cafbd4135331a5b13c9a3034df4b5
|
||||||
size 6110
|
size 6083
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0b6708c60d03e4a0005244a7b7773adcf4f9cfbeb54f2d96c41a80c3ad32b262
|
oid sha256:d060ccab47110ecf584517cf922b1cca0facc705a70ab5d29fc40354b50ac184
|
||||||
size 7759
|
size 7681
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b2ef496a01c45907932fd160d4b906bc90b929d90978466a7ae96f856c0edcb8
|
oid sha256:5e72e218bee1fc66cf8c1f9cf2c2ba9c5654b5738b560ea5728084996345cba4
|
||||||
size 5484
|
size 5471
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4a18f7a783f1b55aa8678e669eabb6be46f45438a3737867e171c889fda6e001
|
oid sha256:79a9c442b826783e3c06ea3dac28f24e836a715da27c70e47cfa2296da0df270
|
||||||
size 7222
|
size 7127
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5c576c7288e434193189c23f75f92b9569c65c878c4dd9e9dc9b3a44af43f792
|
|
||||||
size 5555
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e742874a48ffbfa5322d01537fbd49cacf7e42b98b87eb76bf1459a14fb82ac2
|
|
||||||
size 6353
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b14b7052c2110b2f692472ab8b33631486ab863037106d59e39480203c6432b1
|
|
||||||
size 5375
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4108d9a249b5bb1c0c88dfe54f9df0f5f7b3a5777d6fd5e00d5fd5e41608cf09
|
|
||||||
size 21917
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c41c75f17e291e0be64551202fac5147a0174da7634c1e3645842402f61ae4d9
|
|
||||||
size 5111
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4ec3da0af91751e2d2e949da2f8cb94f4805e7747ba5403afd736a393d58447f
|
|
||||||
size 7145
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1d8c35c24f0b725c56fc54eb025a4c42c69cf3acbad37cc550b0b950812cb20b
|
|
||||||
size 10103
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:43891e66bae441f53f576c0affae0f36c98399f9bfe1d034f0db6ae218da8e47
|
|
||||||
size 8233
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7240b9666592f3627bb2e944cc83ab31eface8139c5c98dcc6c1ad9e7ca9b8b6
|
|
||||||
size 11285
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1
|
|
||||||
size 7507
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6859208aff5071a73cd5c8f9f5fe4437abd793fb564bb4dfc4a3a590a8262a1e
|
|
||||||
size 7946
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1587014aa4cef2ade84e55ab0a9209d0f10b8706164d8e8b28933554dce24d59
|
|
||||||
size 14386
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ac00bcc3150b97a628a03996ce273f68bf5c8ac9db014a7039e3510b4b307726
|
|
||||||
size 5675
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4c0a959f003d8eedf2a4b9284f63ea77a608e272d0dd8e564bbbbf3686c88758
|
|
||||||
size 11572
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d3a3ffdc5062cea8c3226df50b0876a3ca02045a19e05974b95e5ac83b813f39
|
|
||||||
size 11502
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6f5585d6b47d201c1d3d8b1e330b6ebe2bc23811a72f4454add2f563cdeb41f6
|
|
||||||
size 6193
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:05bb4680a0f8afb75b8f32292b54ccc14aac2e868bc122e110c07d4dc2b30915
|
|
||||||
size 8596
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3e3588c936172c143fb195a9113d293858c8bd3678b24a88ca64dd9f3c2cb7a1
|
|
||||||
size 8554
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3cb4d5f28b52e87ce74ca667a330b3b706d1373e6a23c9a9f6a82be9a0460adc
|
|
||||||
size 5494
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:2c169da272ac92d2f9ada1eb90a3c644c54e6788141b4d0ee07c49a59972f567
|
|
||||||
size 6455
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8803bbcece24e3c1dd3eed4055fcb8b8aa1a3d3d06feb5c37813499808c7faf0
|
|
||||||
size 6481
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7f17122c670bd4e9e8f419d40820d29ac75d3f7f0e5d6d1b4e819ddacfe40057
|
|
||||||
size 5922
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7a6c94b36f43c47dea5d2b06b85ec2008b445ee54bffb45be812c3749e7c8fff
|
|
||||||
size 5527
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d8e5ff9c6ba12aee23ad547b71666096f5a184ad94877135587d77e1ed263c99
|
|
||||||
size 6240
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:f91e0c0c857b7e97a31c01a12947843eca7af664d23dec336e30d386575cd955
|
|
||||||
size 5345
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5a3f45d7dffec016578932cfd230bd57a649b2e725943f81c4d157a958826abe
|
|
||||||
size 20718
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:98992d574b00fd9b168541d7826d2290ffb5598ed31aa540ef86eb05b8cad6a0
|
|
||||||
size 5115
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:12cb95c208d0c536200e75e72476f47bd584a039f47a11be86de607cfdca24ff
|
|
||||||
size 6836
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:22011955b67135850138578a5d0c0211541ec1cb2d422487d149c05d2da5537b
|
|
||||||
size 9513
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:43e01f8600b71c4b7d3437a89331cfa2723049b10500b74902b17e8bae82ecaa
|
|
||||||
size 7984
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c9e94e9e5b42dc50ef2958872a2307bb5411e25ab8bf3e696fcc1948d04a602a
|
|
||||||
size 10588
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:9e3e6f73a4ca3e5630d498541e6ba4b62505652e35f8368bda9dbc5cce618456
|
|
||||||
size 7367
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:517d6b30065d903215ab57446f3639c3876d85b85bff4679ffae4c2132775dda
|
|
||||||
size 7567
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:945db7d76ce34a2285e8d083f8c9651204af59aa800ffda4587f854676f717b7
|
|
||||||
size 13529
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:31d2f62b114e1b41d3c7683b2c08e7d70931139e87cf76341d03fb488add4838
|
|
||||||
size 5662
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:51155f13d3795198a301788288351c386e4d509e6ebd3ed103e743762e54a426
|
|
||||||
size 11208
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6199ff6234486bfe7ed87438298086e09e51dcbe4b30a71fa494764f5b3db14c
|
|
||||||
size 11142
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d1dd0872ea0786c5b9c037f05aef2c43d278e450558cb2f2245d76a8051f4836
|
|
||||||
size 6089
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:63953e0cca146083474b69eef7ac861391e111c319ca027e8222dc28cdab7032
|
|
||||||
size 8075
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d5d4c973178bf5793e1231abdc1837aecd16bdb227d0b5128a36db8e1d36290c
|
|
||||||
size 8009
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:aa2431a475eecdf0980bd7bf237e30d8b2e1b98170f42474d8297286386342c0
|
|
||||||
size 5433
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:0179e7d8b68b2bb130e0c6c8db6adde979c724d293918822e6807e9eef627135
|
|
||||||
size 6300
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:506c86795d7940d0cddef77d73eb225b31c9204fc19b81078d95eab71f6d9499
|
|
||||||
size 6301
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:dc0d2daf9d4bda4005f01c604bdc6688673e9a50d5e92ca1668c481327529282
|
|
||||||
size 5831
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue