Merge branch 'develop' into julioromano/poll_history_entry_point
This commit is contained in:
commit
5ac3f273ea
257 changed files with 916 additions and 1161 deletions
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.
|
||||
1
changelog.d/1448.feature
Normal file
1
changelog.d/1448.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Tapping on a user mention pill opens their profile.
|
||||
1
changelog.d/1864.bugfix
Normal file
1
changelog.d/1864.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Accessibility: do not read initial used for avatar out loud.
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
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.telephoto.zoomableimage)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
api(libs.matrix.richtexteditor.compose)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
@ -99,6 +100,8 @@ dependencies {
|
|||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(projects.features.poll.test)
|
||||
testImplementation(projects.features.poll.impl)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
|
|
@ -107,6 +108,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val clipboardHelper: ClipboardHelper,
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
|
|
@ -121,6 +123,8 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): MessagesState {
|
||||
htmlConverterProvider.Update(currentUserId = currentSessionIdHolder.current)
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(null)
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val composerState = composerPresenter.present()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.di.SingleIn
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
|
|
@ -335,7 +336,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
add(Mention.AtRoom)
|
||||
}
|
||||
for (userId in state.userIds) {
|
||||
add(Mention.User(userId))
|
||||
add(Mention.User(UserId(userId)))
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
|
||||
import io.element.android.wysiwyg.compose.StyledHtmlConverter
|
||||
import io.element.android.wysiwyg.display.MentionDisplayHandler
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||
import uniffi.wysiwyg_composer.newMentionDetector
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultHtmlConverterProvider @Inject constructor(): HtmlConverterProvider {
|
||||
|
||||
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
|
||||
|
||||
@Composable
|
||||
override fun Update(currentUserId: UserId) {
|
||||
val isInEditMode = LocalInspectionMode.current
|
||||
val mentionDetector = remember(isInEditMode) {
|
||||
if (isInEditMode) { null } else { newMentionDetector() }
|
||||
}
|
||||
|
||||
val editorStyle = ElementRichTextEditorStyle.textStyle()
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
htmlConverter.value = remember(editorStyle, mentionSpanProvider) {
|
||||
StyledHtmlConverter(
|
||||
context = context,
|
||||
mentionDisplayHandler = object : MentionDisplayHandler {
|
||||
override fun resolveAtRoomMentionDisplay(): TextDisplay {
|
||||
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#"))
|
||||
}
|
||||
|
||||
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
|
||||
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
|
||||
}
|
||||
},
|
||||
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
|
||||
).apply {
|
||||
configureWith(editorStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun provide(): HtmlConverter {
|
||||
return htmlConverter.value ?: error("HtmlConverter wasn't instantiated. Make sure to call HtmlConverterProvider.Update() first.")
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,7 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.features.messages.impl.timeline.model.event.isEdited
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
|
@ -55,7 +54,7 @@ fun TimelineEventTimestampView(
|
|||
) {
|
||||
val formattedTime = event.sentTime
|
||||
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
|
||||
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||
val isMessageEdited = event.content.isEdited()
|
||||
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
|
||||
val clickModifier = if (hasMessageSendingFailed) {
|
||||
Modifier.combinedClickable(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -48,6 +49,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -60,8 +62,8 @@ import androidx.compose.ui.zIndex
|
|||
import androidx.constraintlayout.compose.ConstrainScope
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
||||
|
|
@ -79,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.metadata
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -93,6 +96,9 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -132,6 +138,13 @@ fun TimelineItemEventRow(
|
|||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
fun onMentionClicked(mention: Mention) {
|
||||
when (mention) {
|
||||
is Mention.User -> onUserDataClick(mention.userId)
|
||||
else -> Unit // TODO implement actions for other mentions being clicked
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -176,6 +189,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onMentionClicked = ::onMentionClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -194,6 +208,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onMentionClicked = ::onMentionClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -248,6 +263,7 @@ private fun TimelineItemEventRowContent(
|
|||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
onMentionClicked: (Mention) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -305,13 +321,12 @@ private fun TimelineItemEventRowContent(
|
|||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClicked,
|
||||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
},
|
||||
onMentionClicked = onMentionClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -380,11 +395,10 @@ private fun MessageSenderInformation(
|
|||
@Composable
|
||||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onMessageClick: () -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onMentionClicked: (Mention) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
@SuppressLint("ModifierParameter")
|
||||
@Suppress("ModifierNaming")
|
||||
|
|
@ -473,6 +487,7 @@ private fun MessageEventBubbleContent(
|
|||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val timestampLayoutModifier: Modifier
|
||||
val contentModifier: Modifier
|
||||
when {
|
||||
|
|
@ -506,10 +521,21 @@ private fun MessageEventBubbleContent(
|
|||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onLinkClicked = { url ->
|
||||
when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
|
||||
is PermalinkData.UserLink -> {
|
||||
onMentionClicked(Mention.User(UserId(permalink.userId)))
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
onMentionClicked(Mention.Room(permalink.getRoomId(), permalink.getRoomAlias()))
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
context.openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
},
|
||||
extraPadding = event.toExtraPadding(),
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
|
|||
maxSelections = 1u,
|
||||
answers = persistentListOf(),
|
||||
votes = persistentMapOf(),
|
||||
endTime = null
|
||||
endTime = null,
|
||||
isEdited = false,
|
||||
),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
|
|
|
|||
|
|
@ -80,10 +80,8 @@ fun TimelineItemStateEventRow(
|
|||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onLinkClicked = {},
|
||||
extraPadding = noExtraPadding,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
eventSink = eventSink,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.text.toDp
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Allow to not overlap the timestamp with the text, in the message bubble.
|
||||
// Compute the size of the worst case.
|
||||
|
|
@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
|||
fun ExtraPadding.getStr(fontSize: TextUnit): String {
|
||||
if (nbChars == 0) return ""
|
||||
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
|
||||
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1
|
||||
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).roundToInt() + 1
|
||||
// A space and some unbreakable spaces
|
||||
return " " + "\u00A0".repeat(nbOfSpaces)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
|
|
@ -41,10 +40,8 @@ import io.element.android.libraries.architecture.Presenter
|
|||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
content: TimelineItemEventContent,
|
||||
interactionSource: MutableInteractionSource,
|
||||
extraPadding: ExtraPadding,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onLinkClicked: (url: String) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -63,10 +60,8 @@ fun TimelineItemEventContentView(
|
|||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
onTextClicked = onClick,
|
||||
onTextLongClicked = onLongClick
|
||||
onLinkClicked = onLinkClicked,
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
|
|
|
|||
|
|
@ -16,56 +16,52 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import android.text.SpannableString
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
|
||||
import androidx.core.text.buildSpannedString
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
|
||||
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
fun TimelineItemTextView(
|
||||
content: TimelineItemTextBasedContent,
|
||||
interactionSource: MutableInteractionSource,
|
||||
extraPadding: ExtraPadding,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
|
||||
val htmlDocument = content.htmlDocument
|
||||
if (htmlDocument != null) {
|
||||
HtmlDocument(
|
||||
document = htmlDocument,
|
||||
extraPadding = extraPadding,
|
||||
modifier = modifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
} else {
|
||||
Box(modifier) {
|
||||
val textWithPadding = remember(content.body) {
|
||||
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
val fontSize = LocalTextStyle.current.fontSize
|
||||
|
||||
val formattedBody = content.formattedBody
|
||||
val body = SpannableString(formattedBody ?: content.body)
|
||||
|
||||
Box(modifier) {
|
||||
val textWithPadding = remember(body, fontSize) {
|
||||
buildSpannedString {
|
||||
append(body)
|
||||
append(extraPadding.getStr(fontSize))
|
||||
}
|
||||
ClickableLinkText(
|
||||
text = textWithPadding,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
EditorStyledText(
|
||||
text = textWithPadding,
|
||||
onLinkClickedListener = onLinkClicked,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -77,9 +73,7 @@ internal fun TimelineItemTextViewPreview(
|
|||
) = ElementPreview {
|
||||
TimelineItemTextView(
|
||||
content = content,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
extraPadding = ExtraPadding(nbChars = 8),
|
||||
onTextClicked = {},
|
||||
onTextLongClicked = {},
|
||||
onLinkClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.html
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
open class DocumentProvider : PreviewParameterProvider<Document> {
|
||||
override val values: Sequence<Document>
|
||||
get() = sequenceOf(
|
||||
"text",
|
||||
"<strong>Strong</strong>",
|
||||
"<b>Bold</b>",
|
||||
"<i>Italic</i>",
|
||||
// FIXME This does not work
|
||||
"<b><i>Bold then italic</i></b>",
|
||||
// FIXME This does not work
|
||||
"<i><b>Italic then bold</b></i>",
|
||||
"<em>em</em>",
|
||||
"<unknown>unknown</unknown>",
|
||||
// FIXME `br` is not rendered correctly in the Preview.
|
||||
"Line 1<br/>Line 2",
|
||||
"<code>code</code>",
|
||||
"<del>del</del>",
|
||||
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6><h7>Heading 7</h7>",
|
||||
"<a href=\"https://matrix.org\">link</a>",
|
||||
"<p>paragraph</p>",
|
||||
"<p>paragraph 1</p><p>paragraph 2</p>",
|
||||
"<ol><li>ol item 1</li><li>ol item 2</li></ol>",
|
||||
"<ol><li><i>ol item 1 italic</i></li><li><b>ol item 2 bold</b></li></ol>",
|
||||
"<ul><li>ul item 1</li><li>ul item 2</li></ul>",
|
||||
"<blockquote>blockquote</blockquote>",
|
||||
// TODO Find a way to make is work with `pre`. For now there is an error with
|
||||
// jsoup: java.lang.NoSuchMethodError: 'org.jsoup.nodes.Element org.jsoup.nodes.Element.firstElementChild()'
|
||||
// "<pre>pre</pre>",
|
||||
"<mx-reply><blockquote><a href=\\\"https://matrix.to/#/!roomId/\$eventId?via=matrix.org\\\">In reply to</a> " +
|
||||
"<a href=\\\"https://matrix.to/#/@alice:matrix.org\\\">@alice:matrix.org</a><br>original message</blockquote></mx-reply>reply",
|
||||
"<ol><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ol>",
|
||||
"<ul><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ul>",
|
||||
).map { Jsoup.parse(it) }
|
||||
}
|
||||
|
|
@ -1,635 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalLayoutApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.html
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
|
||||
import io.element.android.features.messages.impl.timeline.components.event.getDpSize
|
||||
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
|
||||
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.theme.LinkColor
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.nodes.TextNode
|
||||
|
||||
private const val CHIP_ID = "chip"
|
||||
|
||||
@Composable
|
||||
fun HtmlDocument(
|
||||
document: Document,
|
||||
extraPadding: ExtraPadding,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
) {
|
||||
HtmlBody(
|
||||
body = document.body(),
|
||||
interactionSource = interactionSource,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier.size(
|
||||
width = extraPadding.getDpSize(),
|
||||
height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlBody(
|
||||
body: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@Composable
|
||||
fun NodesFlowRode(
|
||||
nodes: Iterator<Node>,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
) = FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.Start),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
|
||||
) {
|
||||
var sameRow = true
|
||||
while (sameRow && nodes.hasNext()) {
|
||||
when (val node = nodes.next()) {
|
||||
is TextNode -> {
|
||||
if (!node.isBlank) {
|
||||
ClickableLinkText(
|
||||
text = node.text(),
|
||||
interactionSource = interactionSource,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
is Element -> {
|
||||
if (node.isInline()) {
|
||||
HtmlInline(
|
||||
node,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
} else {
|
||||
HtmlBlock(
|
||||
element = node,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
sameRow = false
|
||||
}
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
val nodesIterator = body.childNodes().iterator()
|
||||
while (nodesIterator.hasNext()) {
|
||||
NodesFlowRode(
|
||||
nodes = nodesIterator,
|
||||
interactionSource = interactionSource,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.isInline(): Boolean {
|
||||
return when (tagName().lowercase()) {
|
||||
"del" -> true
|
||||
"mx-reply" -> false
|
||||
else -> !isBlock
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlBlock(
|
||||
element: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blockModifier = modifier
|
||||
.padding(top = 4.dp)
|
||||
when (element.tagName().lowercase()) {
|
||||
"p" -> HtmlParagraph(
|
||||
paragraph = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(
|
||||
heading = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
"ol" -> HtmlOrderedList(
|
||||
orderedList = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
"ul" -> HtmlUnorderedList(
|
||||
unorderedList = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
"blockquote" -> HtmlBlockquote(
|
||||
blockquote = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
"pre" -> HtmlPreformatted(element, blockModifier)
|
||||
"mx-reply" -> HtmlMxReply(
|
||||
mxReply = element,
|
||||
modifier = blockModifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlInline(
|
||||
element: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier) {
|
||||
val styledText = buildAnnotatedString {
|
||||
appendInlineElement(element, MaterialTheme.colorScheme)
|
||||
}
|
||||
HtmlText(
|
||||
text = styledText,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlPreformatted(
|
||||
pre: Element,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isCode = pre.firstElementChild()?.tagName()?.lowercase() == "code"
|
||||
val backgroundColor =
|
||||
if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified
|
||||
Box(
|
||||
modifier
|
||||
.background(color = backgroundColor)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = pre.wholeText(),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlParagraph(
|
||||
paragraph: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier) {
|
||||
val styledText = buildAnnotatedString {
|
||||
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
|
||||
}
|
||||
HtmlText(
|
||||
text = styledText, onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked, interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlBlockquote(
|
||||
blockquote: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = MaterialTheme.colorScheme.onBackground
|
||||
Box(
|
||||
modifier = modifier
|
||||
.drawBehind {
|
||||
drawLine(
|
||||
color = color,
|
||||
strokeWidth = 2f,
|
||||
start = Offset(12.dp.value, 0f),
|
||||
end = Offset(12.dp.value, size.height)
|
||||
)
|
||||
}
|
||||
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
val text = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
|
||||
}
|
||||
}
|
||||
HtmlText(
|
||||
text = text, onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked, interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlHeading(
|
||||
heading: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val style = when (heading.tagName().lowercase()) {
|
||||
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
|
||||
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
|
||||
"h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp)
|
||||
"h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp)
|
||||
"h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp)
|
||||
"h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp)
|
||||
else -> {
|
||||
return
|
||||
}
|
||||
}
|
||||
Box(modifier) {
|
||||
val text = buildAnnotatedString {
|
||||
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
|
||||
}
|
||||
HtmlText(
|
||||
text = text,
|
||||
style = style,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlMxReply(
|
||||
mxReply: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blockquote = mxReply.childNodes().firstOrNull() ?: return
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.offset(x = (-8).dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shape = shape,
|
||||
) {
|
||||
val text = buildAnnotatedString {
|
||||
for (blockquoteNode in blockquote.childNodes()) {
|
||||
when (blockquoteNode) {
|
||||
is TextNode -> {
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
append(blockquoteNode.text())
|
||||
}
|
||||
}
|
||||
is Element -> {
|
||||
when (blockquoteNode.tagName().lowercase()) {
|
||||
"br" -> {
|
||||
append('\n')
|
||||
}
|
||||
"a" -> {
|
||||
append(blockquoteNode.ownText())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HtmlText(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlOrderedList(
|
||||
orderedList: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val delimiter = "."
|
||||
HtmlListItems(
|
||||
list = orderedList,
|
||||
marker = { index -> "$index$delimiter" },
|
||||
modifier = modifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlUnorderedList(
|
||||
unorderedList: Element,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val marker = "・"
|
||||
HtmlListItems(
|
||||
list = unorderedList,
|
||||
marker = { marker },
|
||||
modifier = modifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlListItems(
|
||||
list: Element,
|
||||
marker: (Int) -> String,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onTextClicked: () -> Unit,
|
||||
onTextLongClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
for ((index, node) in list.children().withIndex()) {
|
||||
val areAllChildrenInline = node.childNodes().all { it is TextNode || it is Element && it.isInline() }
|
||||
if (areAllChildrenInline) {
|
||||
val text = buildAnnotatedString {
|
||||
append("${marker(index + 1)} ")
|
||||
appendInlineChildrenElements(node.childNodes(), MaterialTheme.colorScheme)
|
||||
}
|
||||
HtmlText(
|
||||
text = text,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
)
|
||||
} else {
|
||||
for (innerNode in node.childNodes()) {
|
||||
when (innerNode) {
|
||||
is TextNode -> {
|
||||
if (!innerNode.isBlank) {
|
||||
val text = buildAnnotatedString {
|
||||
append("${marker(index + 1)} ")
|
||||
}
|
||||
HtmlText(
|
||||
text = text,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
is Element -> HtmlBlock(
|
||||
element = innerNode,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ColorScheme.codeBackground(): Color {
|
||||
return background.copy(alpha = 0.3f)
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineChildrenElements(
|
||||
childNodes: List<Node>,
|
||||
colors: ColorScheme
|
||||
) {
|
||||
for (node in childNodes) {
|
||||
when (node) {
|
||||
is TextNode -> {
|
||||
append(node.text())
|
||||
}
|
||||
is Element -> {
|
||||
appendInlineElement(node, colors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) {
|
||||
when (element.tagName().lowercase()) {
|
||||
"br" -> {
|
||||
append('\n')
|
||||
}
|
||||
"code" -> {
|
||||
withStyle(
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = colors.codeBackground()
|
||||
).toSpanStyle()
|
||||
) {
|
||||
appendInlineChildrenElements(element.childNodes(), colors)
|
||||
}
|
||||
}
|
||||
"del" -> {
|
||||
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
appendInlineChildrenElements(element.childNodes(), colors)
|
||||
}
|
||||
}
|
||||
"i",
|
||||
"em" -> {
|
||||
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineChildrenElements(element.childNodes(), colors)
|
||||
}
|
||||
}
|
||||
"strong" -> {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
appendInlineChildrenElements(element.childNodes(), colors)
|
||||
}
|
||||
}
|
||||
"a" -> {
|
||||
appendLink(element)
|
||||
}
|
||||
else -> {
|
||||
appendInlineChildrenElements(element.childNodes(), colors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendLink(link: Element) {
|
||||
val uriString = link.attr("href")
|
||||
val permalinkData = PermalinkParser.parse(uriString)
|
||||
when (permalinkData) {
|
||||
is PermalinkData.FallbackLink -> {
|
||||
pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString())
|
||||
withStyle(
|
||||
style = SpanStyle(color = LinkColor)
|
||||
) {
|
||||
append(link.ownText())
|
||||
}
|
||||
pop()
|
||||
}
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
is PermalinkData.UserLink -> {
|
||||
safeAppendInlineContent(CHIP_ID, link.ownText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AnnotatedString.Builder.safeAppendInlineContent(chipId: String, ownText: String) {
|
||||
if (ownText.isEmpty()) {
|
||||
// alternateText cannot be empty and default parameter value is private,
|
||||
// so just omit the second param here.
|
||||
appendInlineContent(chipId)
|
||||
} else {
|
||||
appendInlineContent(chipId, ownText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HtmlText(
|
||||
text: AnnotatedString,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
val inlineContentMap = persistentMapOf<String, InlineTextContent>()
|
||||
ClickableLinkText(
|
||||
annotatedString = text,
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
inlineContent = inlineContentMap,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HtmlDocumentPreview(@PreviewParameter(DocumentProvider::class) document: Document) = ElementPreview {
|
||||
HtmlDocument(
|
||||
document = document,
|
||||
extraPadding = noExtraPadding,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onTextClicked = {},
|
||||
onTextLongClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -16,7 +16,14 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.factories.event
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
|
|
@ -35,9 +42,11 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
|
|
@ -52,8 +61,9 @@ import kotlin.time.Duration
|
|||
|
||||
class TimelineItemContentMessageFactory @Inject constructor(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
) {
|
||||
|
||||
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
||||
|
|
@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
is EmoteMessageType -> TimelineItemEmoteContent(
|
||||
body = "* $senderDisplayName ${messageType.body}",
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
|
||||
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName"),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
is ImageMessageType -> {
|
||||
|
|
@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
body = messageType.body,
|
||||
htmlDocument = null,
|
||||
plainText = messageType.body,
|
||||
formattedBody = null,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
is NoticeMessageType -> TimelineItemNoticeContent(
|
||||
body = messageType.body,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
formattedBody = parseHtml(messageType.formatted),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
is TextMessageType -> {
|
||||
TimelineItemTextContent(
|
||||
body = messageType.body,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
formattedBody = parseHtml(messageType.formatted),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
}
|
||||
is OtherMessageType -> TimelineItemTextContent(
|
||||
body = messageType.body,
|
||||
htmlDocument = null,
|
||||
formattedBody = null,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
}
|
||||
|
|
@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
|
||||
return result?.takeIf { it.isFinite() }
|
||||
}
|
||||
|
||||
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
|
||||
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
|
||||
val result = htmlConverterProvider.provide()
|
||||
.fromHtmlToSpans(formattedBody.body)
|
||||
.withFixedURLSpans()
|
||||
return if (prefix != null) {
|
||||
buildSpannedString {
|
||||
append(prefix)
|
||||
append(" ")
|
||||
append(result)
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun CharSequence.withFixedURLSpans(): CharSequence {
|
||||
if (this !is Spannable) return this
|
||||
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
|
||||
val oldURLSpans = getSpans<URLSpan>(0, length).associateWith {
|
||||
val start = getSpanStart(it)
|
||||
val end = getSpanEnd(it)
|
||||
Pair(start, end)
|
||||
}
|
||||
// Find and set as URLSpans any links present in the text
|
||||
LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS)
|
||||
// Restore old spans if they don't conflict with the new ones
|
||||
for ((urlSpan, location) in oldURLSpans) {
|
||||
val (start, end) = location
|
||||
if (getSpans<URLSpan>(start, end).isEmpty()) {
|
||||
setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TimelineItemContentPollFactory @Inject constructor(
|
|||
answerItems = pollContentState.answerItems,
|
||||
pollKind = pollContentState.pollKind,
|
||||
isEnded = pollContentState.isPollEnded,
|
||||
isEdited = content.isEdited
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class TimelineItemEmoteContent(
|
|||
override val body: String,
|
||||
override val htmlDocument: Document?,
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||
override val formattedBody: CharSequence?,
|
||||
override val isEdited: Boolean,
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemEmoteContent"
|
||||
|
|
|
|||
|
|
@ -63,3 +63,13 @@ fun TimelineItemEventContent.canReact(): Boolean =
|
|||
is TimelineItemRedactedContent,
|
||||
TimelineItemUnknownContent -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the event content has been edited.
|
||||
*/
|
||||
fun TimelineItemEventContent.isEdited(): Boolean =
|
||||
when (this) {
|
||||
is TimelineItemTextBasedContent -> isEdited
|
||||
is TimelineItemPollContent -> isEdited
|
||||
else -> false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||
override val values = sequenceOf(
|
||||
|
|
@ -43,19 +46,29 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
}
|
||||
|
||||
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
||||
|
||||
private fun buildSpanned(text: String) = buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append("Rich Text")
|
||||
}
|
||||
append(" ")
|
||||
append(text)
|
||||
}
|
||||
|
||||
override val values = sequenceOf(
|
||||
aTimelineItemEmoteContent(),
|
||||
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
|
||||
aTimelineItemEmoteContent().copy(formattedBody = buildSpanned("Emote")),
|
||||
aTimelineItemNoticeContent(),
|
||||
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
|
||||
aTimelineItemNoticeContent().copy(formattedBody = buildSpanned("Notice")),
|
||||
aTimelineItemTextContent(),
|
||||
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")),
|
||||
aTimelineItemTextContent().copy(formattedBody = buildSpanned("Text")),
|
||||
)
|
||||
}
|
||||
|
||||
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
|
||||
body = "Emote",
|
||||
htmlDocument = null,
|
||||
formattedBody = null,
|
||||
isEdited = false,
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +79,7 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
|
|||
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
|
||||
body = "Notice",
|
||||
htmlDocument = null,
|
||||
formattedBody = null,
|
||||
isEdited = false,
|
||||
)
|
||||
|
||||
|
|
@ -74,6 +88,7 @@ fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
|
|||
fun aTimelineItemTextContent() = TimelineItemTextContent(
|
||||
body = "Text",
|
||||
htmlDocument = null,
|
||||
formattedBody = null,
|
||||
isEdited = false,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class TimelineItemNoticeContent(
|
|||
override val body: String,
|
||||
override val htmlDocument: Document?,
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||
override val formattedBody: CharSequence?,
|
||||
override val isEdited: Boolean,
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemNoticeContent"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ data class TimelineItemPollContent(
|
|||
val answerItems: List<PollAnswerItem>,
|
||||
val pollKind: PollKind,
|
||||
val isEnded: Boolean,
|
||||
val isEdited: Boolean
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemPollContent"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ fun aTimelineItemPollContent(
|
|||
isMine: Boolean = false,
|
||||
isEditable: Boolean = false,
|
||||
isEnded: Boolean = false,
|
||||
isEdited: Boolean = false,
|
||||
): TimelineItemPollContent {
|
||||
return TimelineItemPollContent(
|
||||
eventId = EventId("\$anEventId"),
|
||||
|
|
@ -48,5 +49,6 @@ fun aTimelineItemPollContent(
|
|||
isMine = isMine,
|
||||
isEditable = isEditable,
|
||||
isEnded = isEnded,
|
||||
isEdited = isEdited,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
|
|||
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||
val body: String
|
||||
val htmlDocument: Document?
|
||||
val formattedBody: CharSequence?
|
||||
val plainText: String
|
||||
val isEdited: Boolean
|
||||
val htmlBody: String?
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ data class TimelineItemTextContent(
|
|||
override val body: String,
|
||||
override val htmlDocument: Document?,
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||
override val formattedBody: CharSequence?,
|
||||
override val isEdited: Boolean,
|
||||
) : TimelineItemTextBasedContent{
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemTextContent"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
|
|||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
|
|
@ -745,6 +746,7 @@ class MessagesPresenterTest {
|
|||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
currentSessionIdHolder = currentSessionIdHolder,
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||
// val loadingState = awaitItem()
|
||||
|
|
@ -145,7 +145,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
|
||||
// val loadingState = awaitItem()
|
||||
|
|
@ -176,7 +176,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = false,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
|
||||
val successState = awaitItem()
|
||||
|
|
@ -207,7 +207,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||
// val loadingState = awaitItem()
|
||||
|
|
@ -328,7 +328,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||
// val loadingState = awaitItem()
|
||||
|
|
@ -360,7 +360,7 @@ class ActionListPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
|
||||
)
|
||||
val redactedEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
|
|
@ -388,7 +388,7 @@ class ActionListPresenterTest {
|
|||
val messageEvent = aMessageEvent(
|
||||
eventId = null, // No event id, so it's not sent yet
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
|
||||
)
|
||||
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ internal fun aMessageEvent(
|
|||
eventId: EventId? = AN_EVENT_ID,
|
||||
isMine: Boolean = true,
|
||||
isEditable: Boolean = true,
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
|
||||
inReplyTo: InReplyToDetails? = null,
|
||||
isThreaded: Boolean = false,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
|
|||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||
|
|
@ -55,6 +56,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
|||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(),
|
||||
|
|
|
|||
|
|
@ -862,7 +862,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID.value)))
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID)))
|
||||
|
||||
// Check intentional mentions on reply sent
|
||||
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
|
||||
|
|
@ -877,7 +877,7 @@ class MessageComposerPresenterTest {
|
|||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2.value)))
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2)))
|
||||
|
||||
// Check intentional mentions on edit message
|
||||
skipItems(1)
|
||||
|
|
@ -893,7 +893,7 @@ class MessageComposerPresenterTest {
|
|||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3.value)))
|
||||
assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3)))
|
||||
|
||||
skipItems(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultHtmlConverterProviderTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `calling provide without calling Update first should throw an exception`() {
|
||||
val provider = DefaultHtmlConverterProvider()
|
||||
|
||||
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
||||
|
||||
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||
val provider = DefaultHtmlConverterProvider()
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||
provider.Update(currentUserId = A_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
||||
|
||||
assertThat(htmlConverter).isNotNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.factories.event
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.URLSpan
|
||||
import androidx.core.text.inSpans
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
|
|
@ -27,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
|
|
@ -42,10 +47,12 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
|
|
@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class TimelineItemContentMessageFactoryTest {
|
||||
|
||||
@Test
|
||||
|
|
@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest {
|
|||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
isEdited = false,
|
||||
formattedBody = null,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create TextMessageType with HTML formatted body`() = runTest {
|
||||
val expected = SpannableStringBuilder().apply {
|
||||
append("link to ")
|
||||
inSpans(URLSpan("https://matrix.org")) {
|
||||
append("https://matrix.org")
|
||||
}
|
||||
append(" ")
|
||||
inSpans(URLSpan("https://matrix.org")) {
|
||||
append("and manually added link")
|
||||
}
|
||||
}
|
||||
val sut = createTimelineItemContentMessageFactory(
|
||||
htmlConverterTransform = { expected }
|
||||
)
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType(
|
||||
body = "body",
|
||||
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
||||
)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory(
|
||||
htmlConverterTransform = { it }
|
||||
)
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType(
|
||||
body = "body",
|
||||
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
||||
)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create VideoMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
|
|
@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest {
|
|||
body = "body",
|
||||
htmlDocument = null,
|
||||
plainText = "body",
|
||||
formattedBody = null,
|
||||
isEdited = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create NoticeMessageType with HTML formatted body`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = NoticeMessageType(
|
||||
body = "body",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create EmoteMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
|
|
@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest {
|
|||
body = "* Bob body",
|
||||
htmlDocument = null,
|
||||
plainText = "* Bob body",
|
||||
formattedBody = null,
|
||||
isEdited = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create EmoteMessageType with HTML formatted body`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = EmoteMessageType(
|
||||
body = "body",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
|
||||
}
|
||||
|
||||
private fun createMessageContent(
|
||||
body: String = "Body",
|
||||
inReplyTo: InReplyTo? = null,
|
||||
|
|
@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest {
|
|||
}
|
||||
|
||||
private fun createTimelineItemContentMessageFactory(
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
htmlConverterTransform: (String) -> CharSequence = { it },
|
||||
) = TimelineItemContentMessageFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
featureFlagService = featureFlagService,
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -24,4 +24,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,12 +69,12 @@ class DefaultPollContentStateFactory @Inject constructor(
|
|||
|
||||
return PollContentState(
|
||||
eventId = event.eventId,
|
||||
isMine = event.isOwn,
|
||||
question = content.question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
isPollEditable = event.isEditable,
|
||||
isPollEnded = isPollEnded,
|
||||
isMine = event.isOwn,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ class PollContentStateFactoryTest {
|
|||
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
|
||||
votes = votes,
|
||||
endTime = endTime,
|
||||
isEdited = false,
|
||||
)
|
||||
|
||||
private fun aPollContentState(
|
||||
|
|
@ -242,9 +243,9 @@ class PollContentStateFactoryTest {
|
|||
question = question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = pollKind,
|
||||
isMine = isMine,
|
||||
isPollEnded = isEnded,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = isEnded,
|
||||
isMine = isMine,
|
||||
)
|
||||
|
||||
private fun aPollAnswerItem(
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ class FakePollContentStateFactory : PollContentStateFactory {
|
|||
override suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState {
|
||||
return PollContentState(
|
||||
eventId = event.eventId,
|
||||
isMine = event.isOwn,
|
||||
question = content.question,
|
||||
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
isPollEditable = event.isEditable,
|
||||
isPollEnded = content.endTime != null,
|
||||
isMine = event.isOwn
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,10 +75,12 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
.collectAsState(initial = null)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
FeatureFlags.entries.forEach { feature ->
|
||||
features[feature.key] = feature
|
||||
enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature)
|
||||
}
|
||||
FeatureFlags.entries
|
||||
.filter { it.isFinished.not() }
|
||||
.forEach { feature ->
|
||||
features[feature.key] = feature
|
||||
enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature)
|
||||
}
|
||||
}
|
||||
val featureUiModels = createUiModels(features, enabledFeatures)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ class DeveloperSettingsPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
assertThat(state.features).hasSize(FeatureFlags.entries.size)
|
||||
val state = awaitLastSequentialItem()
|
||||
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
|
||||
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ core = "1.12.0"
|
|||
datastore = "1.0.0"
|
||||
constraintlayout = "2.1.4"
|
||||
constraintlayout_compose = "1.0.1"
|
||||
lifecycle = "2.7.0-rc01"
|
||||
lifecycle = "2.7.0-rc02"
|
||||
activity = "1.8.1"
|
||||
media3 = "1.2.0"
|
||||
|
||||
|
|
@ -67,10 +67,10 @@ firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdi
|
|||
# AndroidX
|
||||
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
||||
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
||||
androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.7.0"
|
||||
androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.7.1"
|
||||
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
||||
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
|
||||
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" }
|
||||
|
||||
|
|
@ -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_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
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_icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||
|
||||
|
|
@ -211,8 +212,8 @@ anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
|
|||
detekt = "io.gitlab.arturbosch.detekt:1.23.4"
|
||||
ktlint = "org.jlleitschuh.gradle.ktlint:12.0.3"
|
||||
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
|
||||
dependencycheck = "org.owasp.dependencycheck:9.0.4"
|
||||
dependencyanalysis = "com.autonomousapps.dependency-analysis:1.27.0"
|
||||
dependencycheck = "org.owasp.dependencycheck:9.0.5"
|
||||
dependencyanalysis = "com.autonomousapps.dependency-analysis:1.28.0"
|
||||
paparazzi = "app.cash.paparazzi:1.3.1"
|
||||
kover = "org.jetbrains.kotlinx.kover:0.6.1"
|
||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||
|
|
|
|||
|
|
@ -27,18 +27,19 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
|
|
@ -96,7 +97,9 @@ private fun InitialsAvatar(
|
|||
val ratio = fontSize.value / originalFont.fontSize.value
|
||||
val lineHeight = originalFont.lineHeight * ratio
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
modifier = Modifier
|
||||
.clearAndSetSemantics {}
|
||||
.align(Alignment.Center),
|
||||
text = avatarData.initial,
|
||||
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
|
||||
color = avatarColors.foreground,
|
||||
|
|
|
|||
|
|
@ -36,4 +36,11 @@ interface Feature {
|
|||
* The default value of the feature (enabled or disabled).
|
||||
*/
|
||||
val defaultValue: Boolean
|
||||
|
||||
/**
|
||||
* Whether the feature is finished or not.
|
||||
* If false: the feature is still in development, it will appear in the developer options screen to be able to enable it and test it.
|
||||
* If true: the feature is finished, it will not appear in the developer options screen.
|
||||
*/
|
||||
val isFinished: Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,52 +25,61 @@ enum class FeatureFlags(
|
|||
override val key: String,
|
||||
override val title: String,
|
||||
override val description: String? = null,
|
||||
override val defaultValue: Boolean
|
||||
override val defaultValue: Boolean,
|
||||
override val isFinished: Boolean,
|
||||
) : Feature {
|
||||
LocationSharing(
|
||||
key = "feature.locationsharing",
|
||||
title = "Allow user to share location",
|
||||
defaultValue = true,
|
||||
isFinished = true,
|
||||
),
|
||||
Polls(
|
||||
key = "feature.polls",
|
||||
title = "Polls",
|
||||
description = "Create poll and render poll events in the timeline",
|
||||
defaultValue = true,
|
||||
isFinished = true,
|
||||
),
|
||||
NotificationSettings(
|
||||
key = "feature.notificationsettings",
|
||||
title = "Show notification settings",
|
||||
defaultValue = true,
|
||||
isFinished = true,
|
||||
),
|
||||
VoiceMessages(
|
||||
key = "feature.voicemessages",
|
||||
title = "Voice messages",
|
||||
description = "Send and receive voice messages",
|
||||
defaultValue = true,
|
||||
isFinished = true,
|
||||
),
|
||||
PinUnlock(
|
||||
key = "feature.pinunlock",
|
||||
title = "Pin unlock",
|
||||
description = "Allow user to lock/unlock the app with a pin code or biometrics",
|
||||
defaultValue = true,
|
||||
isFinished = true,
|
||||
),
|
||||
Mentions(
|
||||
key = "feature.mentions",
|
||||
title = "Mentions",
|
||||
description = "Type `@` to get mention suggestions and insert them",
|
||||
defaultValue = false,
|
||||
isFinished = false,
|
||||
),
|
||||
SecureStorage(
|
||||
key = "feature.securestorage",
|
||||
title = "Chat backup",
|
||||
description = "Allow access to backup and restore chat history settings",
|
||||
defaultValue = false,
|
||||
isFinished = false,
|
||||
),
|
||||
ReadReceipts(
|
||||
key = "feature.readreceipts",
|
||||
title = "Show read receipts",
|
||||
description = null,
|
||||
defaultValue = false,
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.permalink
|
|||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
|
|
@ -32,7 +33,15 @@ sealed interface PermalinkData {
|
|||
val isRoomAlias: Boolean,
|
||||
val eventId: String?,
|
||||
val viaParameters: ImmutableList<String>
|
||||
) : PermalinkData
|
||||
) : PermalinkData {
|
||||
fun getRoomId(): RoomId? {
|
||||
return roomIdOrAlias.takeIf { !isRoomAlias }?.let(::RoomId)
|
||||
}
|
||||
|
||||
fun getRoomAlias(): String? {
|
||||
return roomIdOrAlias.takeIf { isRoomAlias }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* &room_name=Team2
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
sealed interface Mention {
|
||||
data class User(val userId: String): Mention
|
||||
data class User(val userId: UserId): Mention
|
||||
data object AtRoom: Mention
|
||||
data class Room(val roomId: RoomId?, val roomAlias: String?): Mention
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ data class PollContent(
|
|||
val maxSelections: ULong,
|
||||
val answers: ImmutableList<PollAnswer>,
|
||||
val votes: ImmutableMap<String, ImmutableList<UserId>>,
|
||||
val endTime: ULong?
|
||||
val endTime: ULong?,
|
||||
val isEdited: Boolean,
|
||||
) : EventContent
|
||||
|
||||
data class UnableToDecryptContent(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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.libraries.matrix.api.permalink
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
|
||||
class PermalinkDataTest {
|
||||
|
||||
@Test
|
||||
fun `getRoomId() returns value when isRoomAlias is false`() {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = "!abcdef123456:matrix.org",
|
||||
isRoomAlias = false,
|
||||
eventId = null,
|
||||
viaParameters = persistentListOf(),
|
||||
)
|
||||
assertThat(permalinkData.getRoomId()).isNotNull()
|
||||
assertThat(permalinkData.getRoomAlias()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getRoomAlias() returns value when isRoomAlias is true`() {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = "#room:matrix.org",
|
||||
isRoomAlias = true,
|
||||
eventId = null,
|
||||
viaParameters = persistentListOf(),
|
||||
)
|
||||
assertThat(permalinkData.getRoomId()).isNull()
|
||||
assertThat(permalinkData.getRoomAlias()).isNotNull()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -24,11 +24,20 @@ class BackupUploadStateMapper {
|
|||
return when (rustEnableProgress) {
|
||||
RustBackupUploadState.Done ->
|
||||
BackupUploadState.Done
|
||||
is RustBackupUploadState.Uploading ->
|
||||
BackupUploadState.Uploading(
|
||||
backedUpCount = rustEnableProgress.backedUpCount.toInt(),
|
||||
totalCount = rustEnableProgress.totalCount.toInt(),
|
||||
)
|
||||
is RustBackupUploadState.Uploading -> {
|
||||
val backedUpCount = rustEnableProgress.backedUpCount.toInt()
|
||||
val totalCount = rustEnableProgress.totalCount.toInt()
|
||||
if (backedUpCount == totalCount) {
|
||||
// Consider that the state is Done in this case,
|
||||
// the SDK will not send a Done state
|
||||
BackupUploadState.Done
|
||||
} else {
|
||||
BackupUploadState.Uploading(
|
||||
backedUpCount = backedUpCount,
|
||||
totalCount = totalCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
RustBackupUploadState.Waiting ->
|
||||
BackupUploadState.Waiting
|
||||
RustBackupUploadState.Error ->
|
||||
|
|
|
|||
|
|
@ -21,6 +21,6 @@ import org.matrix.rustcomponents.sdk.Mentions
|
|||
|
||||
fun List<Mention>.map(): Mentions {
|
||||
val hasAtRoom = any { it is Mention.AtRoom }
|
||||
val userIds = filterIsInstance<Mention.User>().map { it.userId }
|
||||
val userIds = filterIsInstance<Mention.User>().map { it.userId.value }
|
||||
return Mentions(userIds, hasAtRoom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
|
|||
vote.value.map { userId -> UserId(userId) }.toImmutableList()
|
||||
}.toImmutableMap(),
|
||||
endTime = kind.endTime,
|
||||
isEdited = kind.hasBeenEdited,
|
||||
)
|
||||
}
|
||||
is TimelineItemContentKind.UnableToDecrypt -> {
|
||||
|
|
|
|||
|
|
@ -16,32 +16,52 @@
|
|||
|
||||
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.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
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.libraries.designsystem.theme.bgSubtleTertiary
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorStyle
|
||||
|
||||
internal object ElementRichTextEditorStyle {
|
||||
object ElementRichTextEditorStyle {
|
||||
@Composable
|
||||
fun create(
|
||||
fun composerStyle(
|
||||
hasFocus: Boolean,
|
||||
) : 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 m3colors = MaterialTheme.colorScheme
|
||||
val codeCornerRadius = 4.dp
|
||||
val codeBorderWidth = 1.dp
|
||||
return RichTextEditorDefaults.style(
|
||||
text = RichTextEditorDefaults.textStyle(
|
||||
color = if (hasFocus) {
|
||||
m3colors.primary
|
||||
} else {
|
||||
m3colors.secondary
|
||||
},
|
||||
lineHeight = 16.25.sp,
|
||||
color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
|
||||
fontStyle = LocalTextStyle.current.fontStyle,
|
||||
lineHeight = LocalTextStyle.current.lineHeight,
|
||||
includeFontPadding = false,
|
||||
),
|
||||
cursor = RichTextEditorDefaults.cursorStyle(
|
||||
color = colors.iconAccentTertiary,
|
||||
|
|
|
|||
|
|
@ -441,9 +441,7 @@ private fun TextInput(
|
|||
modifier = Modifier
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
style = ElementRichTextEditorStyle.create(
|
||||
hasFocus = state.hasFocus
|
||||
),
|
||||
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||
onError = onError
|
||||
|
|
|
|||
|
|
@ -23,20 +23,59 @@ import android.text.style.ReplacementSpan
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
class MentionSpan(
|
||||
val type: Type,
|
||||
val backgroundColor: Int,
|
||||
val textColor: Int,
|
||||
) : ReplacementSpan() {
|
||||
|
||||
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) {
|
||||
val textSize = paint.measureText(text, start, end)
|
||||
val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat())
|
||||
val mentionText = getActualText(text, start)
|
||||
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
|
||||
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
|
||||
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 -> {
|
||||
val isCurrentUser = permalinkData.userId == currentSessionId.value
|
||||
MentionSpan(
|
||||
type = MentionSpan.Type.USER,
|
||||
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
||||
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
||||
)
|
||||
}
|
||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
||||
MentionSpan(
|
||||
type = MentionSpan.Type.USER,
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
MentionSpan(
|
||||
type = MentionSpan.Type.ROOM,
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
)
|
||||
|
|
@ -97,17 +100,26 @@ internal fun MentionSpanPreview() {
|
|||
provider.setup()
|
||||
|
||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||
val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
||||
val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
||||
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me: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 ->
|
||||
TextView(context).apply {
|
||||
includeFontPadding = false
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
text = buildSpannedString {
|
||||
append("This is a ")
|
||||
append("@mention", mentionSpan, 0)
|
||||
append("@mention", mentionSpanMe(), 0)
|
||||
append(" to the current user and this is a ")
|
||||
append("@mention", mentionSpan2, 0)
|
||||
append(" to other user")
|
||||
append("@mention", mentionSpanOther(), 0)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,14 +46,21 @@ class MentionSpanProviderTest {
|
|||
|
||||
@Test
|
||||
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.textColor).isEqualTo(myUserColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
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.textColor).isEqualTo(otherColor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1ea2576745ff79c9fb37a54ecf9d84c0c6849404f108bd695d8332e42f8e2082
|
||||
size 5916
|
||||
oid sha256:7c32500fa620506fb7cbf912793e5919ae2c4da8a6acf83ecd2ad344d95ea4be
|
||||
size 5937
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a7dbee314d26d3dceef67818374250c201b68b889e2e4a14224405e58737331
|
||||
size 7879
|
||||
oid sha256:4256e9443e028d232420062300ee0c36c540b7430fa3e7ccea71304526532c97
|
||||
size 7770
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:afc0f5d66d83f219c220d8136041abc70776d171bc9f13e3dbe3cb39740a2136
|
||||
size 6199
|
||||
oid sha256:5684ce9c6111eed345950e036126271bf9cabf535ecea54bf81c370e941051f5
|
||||
size 6247
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8d6d5765db221efdd927da30a10dafe6f4c5851c44539a3d90efe84601cf5adb
|
||||
size 8168
|
||||
oid sha256:edca1a2ee9211e0595c45f9fe6c4c830c27e5e4edb75d3bf9fb7b3514b8a91fc
|
||||
size 8042
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fd7b233c6973e3a048c1abe50f09d9740dd4a6da0b44b1d56a9b301d9cb1f9ec
|
||||
size 5518
|
||||
oid sha256:6279e616bd3f912654774495ee38914b15f5ab89449e49e6d03509a7f719ae9e
|
||||
size 5515
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a2cdb0b10daa1ab865334f8447fc14a30a0c79a43be5d2c94f5da8d981fa0187
|
||||
size 7543
|
||||
oid sha256:78c1bd81fb40b7adc968e0706bc392e02848a5bac536ada5143f27efed15fb77
|
||||
size 7445
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a490c7c4b58885a06fd89f2cbc8a832e50ba58fba1483e327796a3ef5b6d0c23
|
||||
size 5851
|
||||
oid sha256:61912b822c573d1cbb730dbdada9825d4508f04f947473044ff1e5344e113c11
|
||||
size 5846
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57c45ab46932c22290987266d5bdfbe392c1c6301803e7176545e3b1ed74b3d7
|
||||
size 7549
|
||||
oid sha256:af0e2591d88eb92b12d910e6efa29f1673f0702bf0ab56fa9179ad39f9168ba4
|
||||
size 7399
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b38b8ec214dae0b5e0b05c5fd2c315a73004bbc5a607440a521587ff05780120
|
||||
size 6110
|
||||
oid sha256:f34118dc5296d9b564d932bbf0a91944e31cafbd4135331a5b13c9a3034df4b5
|
||||
size 6083
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0b6708c60d03e4a0005244a7b7773adcf4f9cfbeb54f2d96c41a80c3ad32b262
|
||||
size 7759
|
||||
oid sha256:d060ccab47110ecf584517cf922b1cca0facc705a70ab5d29fc40354b50ac184
|
||||
size 7681
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b2ef496a01c45907932fd160d4b906bc90b929d90978466a7ae96f856c0edcb8
|
||||
size 5484
|
||||
oid sha256:5e72e218bee1fc66cf8c1f9cf2c2ba9c5654b5738b560ea5728084996345cba4
|
||||
size 5471
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a18f7a783f1b55aa8678e669eabb6be46f45438a3737867e171c889fda6e001
|
||||
size 7222
|
||||
oid sha256:79a9c442b826783e3c06ea3dac28f24e836a715da27c70e47cfa2296da0df270
|
||||
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
|
||||
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