diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index c81cbebaae..ea3584e4f5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -16,9 +16,12 @@ package io.element.android.features.messages.impl +import android.content.Context +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -31,11 +34,14 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId 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.MatrixRoom import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer @@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor( presenterFactory: MessagesPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val mediaPlayer: MediaPlayer, + private val permalinkParser: PermalinkParser, ) : Node(buildContext, plugins = plugins), MessagesNavigator { private val presenter = presenterFactory.create(this) private val callback = plugins().firstOrNull() @@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor( private fun onUserDataClicked(userId: UserId) { callback?.onUserDataClicked(userId) } + + private fun onLinkClicked( + context: Context, + url: String, + ) { + when (val permalink = permalinkParser.parse(Uri.parse(url))) { + is PermalinkData.UserLink -> { + onUserDataClicked(UserId(permalink.userId)) + } + is PermalinkData.RoomLink -> { + // TODO: Implement room link handling + } + is PermalinkData.FallbackLink, + is PermalinkData.RoomEmailInviteLink -> { + context.openUrlInExternalApp(url) + } + } + } + override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { callback?.onShowEventDebugInfoClicked(eventId, debugInfo) } @@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { + val context = LocalContext.current CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { @@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor( onEventClicked = this::onEventClicked, onPreviewAttachments = this::onPreviewAttachments, onUserDataClicked = this::onUserDataClicked, + onLinkClicked = { onLinkClicked(context, it) }, onSendLocationClicked = this::onSendLocationClicked, onCreatePollClicked = this::onCreatePollClicked, onJoinCallClicked = this::onJoinCallClicked, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 91cccd88e8..9cf4375769 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -119,6 +119,7 @@ fun MessagesView( onRoomDetailsClicked: () -> Unit, onEventClicked: (event: TimelineItem.Event) -> Boolean, onUserDataClicked: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onPreviewAttachments: (ImmutableList) -> Unit, onSendLocationClicked: () -> Unit, onCreatePollClicked: () -> Unit, @@ -213,6 +214,7 @@ fun MessagesView( onMessageClicked = ::onMessageClicked, onMessageLongClicked = ::onMessageLongClicked, onUserDataClicked = onUserDataClicked, + onLinkClicked = onLinkClicked, onTimestampClicked = { event -> if (event.localSendState is LocalEventSendState.SendingFailed) { state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) @@ -313,6 +315,7 @@ private fun MessagesViewContent( state: MessagesState, onMessageClicked: (TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit, @@ -386,6 +389,7 @@ private fun MessagesViewContent( onMessageClicked = onMessageClicked, onMessageLongClicked = onMessageLongClicked, onUserDataClicked = onUserDataClicked, + onLinkClicked = onLinkClicked, onTimestampClicked = onTimestampClicked, onReactionClicked = onReactionClicked, onReactionLongClicked = onReactionLongClicked, @@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onEventClicked = { false }, onPreviewAttachments = {}, onUserDataClicked = {}, + onLinkClicked = {}, onSendLocationClicked = {}, onCreatePollClicked = {}, onJoinCallClicked = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 37e404d314..04c182a566 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -49,6 +49,7 @@ 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.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder @@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor( private val messageComposerContext: MessageComposerContextImpl, private val richTextEditorStateFactory: RichTextEditorStateFactory, private val currentSessionIdHolder: CurrentSessionIdHolder, + private val permalinkParser: PermalinkParser, + private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) @@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor( } is MentionSuggestion.Member -> { val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch richTextEditorState.insertMentionAtSuggestion(text = text, link = link) } } @@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor( return MessageComposerState( richTextEditorState = richTextEditorState, + permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, showAttachmentSourcePicker = showAttachmentSourcePicker, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 09eb51477f..194ce1914c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Stable import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList @@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList @Stable data class MessageComposerState( val richTextEditorState: RichTextEditorState, + val permalinkParser: PermalinkParser, val isFullScreen: Boolean, val mode: MessageComposerMode, val showAttachmentSourcePicker: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index da3c0c8af7..fb7f616e12 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -16,9 +16,12 @@ package io.element.android.features.messages.impl.messagecomposer +import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.mentions.MentionSuggestion 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.textcomposer.model.MessageComposerMode import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList @@ -43,6 +46,10 @@ fun aMessageComposerState( memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( richTextEditorState = richTextEditorState, + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData = TODO() + override fun parse(uri: Uri): PermalinkData = TODO() + }, isFullScreen = isFullScreen, mode = mode, showTextFormatting = showTextFormatting, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 6cb2900a20..e68cf29844 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -109,6 +109,7 @@ internal fun MessageComposerView( modifier = modifier, state = state.richTextEditorState, voiceMessageState = voiceMessageState.voiceMessageState, + permalinkParser = state.permalinkParser, subcomposing = subcomposing, onRequestFocus = ::onRequestFocus, onSendMessage = ::sendMessage, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt index aefadcbb25..26ad72dcf9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt @@ -28,6 +28,7 @@ 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.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.wysiwyg.compose.StyledHtmlConverter @@ -39,7 +40,9 @@ import javax.inject.Inject @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) -class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider { +class DefaultHtmlConverterProvider @Inject constructor( + private val permalinkParser: PermalinkParser, +) : HtmlConverterProvider { private val htmlConverter: MutableState = mutableStateOf(null) @Composable @@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider } val editorStyle = ElementRichTextEditorStyle.textStyle() - val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId) + val mentionSpanProvider = rememberMentionSpanProvider( + currentUserId = currentUserId, + permalinkParser = permalinkParser, + ) val context = LocalContext.current diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 094ecb6c3b..5869238753 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -81,6 +81,7 @@ fun TimelineView( typingNotificationState: TypingNotificationState, roomName: String?, onUserDataClicked: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, @@ -140,6 +141,7 @@ fun TimelineView( onClick = onMessageClicked, onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, + onLinkClicked = onLinkClicked, inReplyToClick = ::inReplyToClicked, onReactionClick = onReactionClicked, onReactionLongClick = onReactionLongClicked, @@ -276,6 +278,7 @@ internal fun TimelineViewPreview( onMessageClicked = {}, onTimestampClicked = {}, onUserDataClicked = {}, + onLinkClicked = {}, onMessageLongClicked = {}, onReactionClicked = { _, _ -> }, onReactionLongClicked = { _, _ -> }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index a86095b3fc..92b12b2dc8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow( onClick = {}, onLongClick = {}, onUserDataClick = {}, + onLinkClicked = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 81c10ad7d8..22bce0e873 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -17,7 +17,6 @@ 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 @@ -50,7 +49,6 @@ 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 @@ -94,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo 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 @@ -109,9 +106,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text 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.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings @@ -128,6 +122,7 @@ fun TimelineItemEventRow( isHighlighted: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, + onLinkClicked: (String) -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, @@ -151,13 +146,6 @@ 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)) @@ -203,7 +191,7 @@ fun TimelineItemEventRow( onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onMentionClicked = ::onMentionClicked, + onLinkClicked = onLinkClicked, eventSink = eventSink, ) } @@ -222,7 +210,7 @@ fun TimelineItemEventRow( onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onMentionClicked = ::onMentionClicked, + onLinkClicked = onLinkClicked, eventSink = eventSink, ) } @@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent( onReactionClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, - onMentionClicked: (Mention) -> Unit, + onLinkClicked: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent( onTimestampClicked = { onTimestampClicked(event) }, - onMentionClicked = onMentionClicked, + onLinkClicked = onLinkClicked, eventSink = eventSink, ) } @@ -429,7 +417,7 @@ private fun MessageEventBubbleContent( @Suppress("UNUSED_PARAMETER") inReplyToClick: () -> Unit, onTimestampClicked: () -> Unit, - onMentionClicked: (Mention) -> Unit, + onLinkClicked: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @SuppressLint("ModifierParameter") // need to rename this modifier to prevent linter false positives @@ -530,7 +518,6 @@ private fun MessageEventBubbleContent( modifier: Modifier = Modifier, canShrinkContent: Boolean = false, ) { - val context = LocalContext.current val timestampLayoutModifier: Modifier val contentModifier: Modifier when { @@ -566,20 +553,7 @@ private fun MessageEventBubbleContent( ) { onContentLayoutChanged -> TimelineItemEventContentView( content = event.content, - 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) - } - } - }, + onLinkClicked = onLinkClicked, eventSink = eventSink, onContentLayoutChanged = onContentLayoutChanged, modifier = contentModifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 74998afb89..bc05882f1b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -51,6 +51,7 @@ fun TimelineItemGroupedEventsRow( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, @@ -78,6 +79,7 @@ fun TimelineItemGroupedEventsRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, + onLinkClicked = onLinkClicked, onTimestampClicked = onTimestampClicked, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -102,6 +104,7 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, @@ -135,6 +138,7 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, + onLinkClicked = onLinkClicked, onTimestampClicked = onTimestampClicked, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -175,6 +179,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, + onLinkClicked = {}, onTimestampClicked = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, @@ -200,6 +205,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, + onLinkClicked = {}, onTimestampClicked = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 6acddd179d..5146c87df6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -36,6 +36,7 @@ internal fun TimelineItemRow( highlightedItem: String?, sessionState: SessionState, onUserDataClick: (UserId) -> Unit, + onLinkClicked: (String) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, @@ -79,6 +80,7 @@ internal fun TimelineItemRow( onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onUserDataClick = onUserDataClick, + onLinkClicked = onLinkClicked, inReplyToClick = inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -103,6 +105,7 @@ internal fun TimelineItemRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, + onLinkClicked = onLinkClicked, onTimestampClicked = onTimestampClicked, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index af1ef0bc3a..acd99d3704 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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 @@ -67,6 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor( private val fileExtensionExtractor: FileExtensionExtractor, private val featureFlagService: FeatureFlagService, private val htmlConverterProvider: HtmlConverterProvider, + private val permalinkParser: PermalinkParser, ) { suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent { return when (val messageType = content.type) { @@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor( val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}" TimelineItemEmoteContent( body = emoteBody, - htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"), + htmlDocument = messageType.formatted?.toHtmlDocument( + permalinkParser = permalinkParser, + prefix = "* $senderDisplayName", + ), formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(), isEdited = content.isEdited, ) @@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val body = messageType.body.trimEnd() TimelineItemNoticeContent( body = body, - htmlDocument = messageType.formatted?.toHtmlDocument(), + htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), isEdited = content.isEdited, ) @@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, - htmlDocument = messageType.formatted?.toHtmlDocument(), + htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), isEdited = content.isEdited, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 38125dcef7..0522379f72 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails @@ -43,6 +44,7 @@ class TimelineItemEventFactory @Inject constructor( private val contentFactory: TimelineItemContentFactory, private val matrixClient: MatrixClient, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, + private val permalinkParser: PermalinkParser, ) { suspend fun create( currentTimelineItem: MatrixTimelineItem.Event, @@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor( reactionsState = currentTimelineItem.computeReactionsState(), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), localSendState = currentTimelineItem.event.localSendState, - inReplyTo = currentTimelineItem.event.inReplyTo()?.map(), + inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), isThreaded = currentTimelineItem.event.isThreaded(), debugInfo = currentTimelineItem.event.debugInfo, origin = currentTimelineItem.event.origin, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt index 759ead5b61..3c629f23fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model 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.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent @@ -34,7 +35,9 @@ data class InReplyToDetails( val textContent: String?, ) -fun InReplyTo.map() = when (this) { +fun InReplyTo.map( + permalinkParser: PermalinkParser, +) = when (this) { is InReplyTo.Ready -> InReplyToDetails( eventId = eventId, senderId = senderId, @@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) { textContent = when (content) { is MessageContent -> { val messageContent = content as MessageContent - (messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body + (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body } is StickerContent -> { val stickerContent = content as StickerContent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt index 2fea39eeb7..3b09afb7fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt @@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview( onEventClicked = { false }, onPreviewAttachments = {}, onUserDataClicked = {}, + onLinkClicked = {}, onSendLocationClicked = {}, onCreatePollClicked = {}, onJoinCallClicked = {}, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 30413d1b9f..6cceb6b5b5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -77,6 +77,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember @@ -725,6 +727,8 @@ class MessagesPresenterTest { richTextEditorStateFactory = TestRichTextEditorStateFactory(), permissionsPresenterFactory = permissionsPresenterFactory, currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), + permalinkParser = FakePermalinkParser(), + permalinkBuilder = FakePermalinkBuilder(), ) val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index b931dc9860..e176e20805 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -478,6 +478,7 @@ private fun AndroidComposeTestRule.setMessa onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(), onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(), onSendLocationClicked: () -> Unit = EnsureNeverCalled(), onCreatePollClicked: () -> Unit = EnsureNeverCalled(), @@ -492,6 +493,7 @@ private fun AndroidComposeTestRule.setMessa onRoomDetailsClicked = onRoomDetailsClicked, onEventClicked = onEventClicked, onUserDataClicked = onUserDataClicked, + onLinkClicked = onLinkClicked, onPreviewAttachments = onPreviewAttachments, onSendLocationClicked = onSendLocationClicked, onCreatePollClicked = onCreatePollClicked, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index 562424474c..db056dea19 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope @@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), featureFlagService = FakeFeatureFlagService(), htmlConverterProvider = FakeHtmlConverterProvider(), + permalinkParser = FakePermalinkParser(), ), redactedMessageFactory = TimelineItemContentRedactedFactory(), stickerFactory = TimelineItemContentStickerFactory( @@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { ), matrixClient = matrixClient, lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(), + permalinkParser = FakePermalinkParser(), ), virtualItemFactory = TimelineItemVirtualFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 5e980514cb..290f297117 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo +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.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.Mention @@ -60,6 +61,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.mediapickers.api.PickerProvider @@ -805,7 +808,14 @@ class MessageComposerPresenterTest { @Test fun `present - insertMention`() = runTest { - val presenter = createPresenter(this) + val presenter = createPresenter( + coroutineScope = this, + permalinkBuilder = FakePermalinkBuilder( + result = { + Result.success("https://matrix.to/#/${A_USER_ID_2.value}") + } + ) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -941,6 +951,7 @@ class MessageComposerPresenterTest { mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder() ) = MessageComposerPresenter( coroutineScope, room, @@ -955,6 +966,8 @@ class MessageComposerPresenterTest { TestRichTextEditorStateFactory(), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), + permalinkParser = FakePermalinkParser(), + permalinkBuilder = permalinkBuilder, ) private suspend fun ReceiveTurbine.awaitFirstItem(): T { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt index 276544a057..7411ceda1a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt @@ -21,6 +21,7 @@ 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 io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest { @Test fun `calling provide without calling Update first should throw an exception`() { - val provider = DefaultHtmlConverterProvider() + val provider = DefaultHtmlConverterProvider( + permalinkParser = FakePermalinkParser(), + ) val exception = runCatching { provider.provide() }.exceptionOrNull() @@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest { @Test fun `calling provide after calling Update first should return an HtmlConverter`() { - val provider = DefaultHtmlConverterProvider() + val provider = DefaultHtmlConverterProvider( + permalinkParser = FakePermalinkParser(), + ) composeTestRule.setContent { CompositionLocalProvider(LocalInspectionMode provides true) { provider.Update(currentUserId = A_USER_ID) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 78cd671e3f..44fb6270ae 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -45,6 +45,7 @@ class TimelineViewTest { typingNotificationState = aTypingNotificationState(), roomName = null, onUserDataClicked = EnsureNeverCalledWithParam(), + onLinkClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(), @@ -72,6 +73,7 @@ class TimelineViewTest { typingNotificationState = aTypingNotificationState(), roomName = null, onUserDataClicked = EnsureNeverCalledWithParam(), + onLinkClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 98953f4827..6e475707ad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import kotlinx.collections.immutable.persistentListOf @@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest { fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), featureFlagService = featureFlagService, htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), + permalinkParser = FakePermalinkParser(), ) private fun createStickerContent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt index f07a73fd84..bf287341ad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt @@ -26,14 +26,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import org.junit.Test class InReplyToDetailTest { @Test fun `map - with a not ready InReplyTo does not work`() { - assertThat(InReplyTo.Pending.map()).isNull() - assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull() - assertThat(InReplyTo.Error.map()).isNull() + assertThat( + InReplyTo.Pending.map( + permalinkParser = FakePermalinkParser() + ) + ).isNull() + assertThat( + InReplyTo.NotLoaded(AN_EVENT_ID).map( + permalinkParser = FakePermalinkParser() + ) + ).isNull() + assertThat( + InReplyTo.Error.map( + permalinkParser = FakePermalinkParser() + ) + ).isNull() } @Test @@ -48,7 +61,9 @@ class InReplyToDetailTest { change = MembershipChange.INVITED, ) ) - val inReplyToDetails = inReplyTo.map() + val inReplyToDetails = inReplyTo.map( + permalinkParser = FakePermalinkParser() + ) assertThat(inReplyToDetails).isNotNull() assertThat(inReplyToDetails?.textContent).isNull() } @@ -74,7 +89,11 @@ class InReplyToDetailTest { ) ) ) - assertThat(inReplyTo.map()?.textContent).isEqualTo("Hello!") + assertThat( + inReplyTo.map( + permalinkParser = FakePermalinkParser() + )?.textContent + ).isEqualTo("Hello!") } @Test @@ -95,6 +114,10 @@ class InReplyToDetailTest { ) ) ) - assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**") + assertThat( + inReplyTo.map( + permalinkParser = FakePermalinkParser() + )?.textContent + ).isEqualTo("**Hello!**") } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 63cc745ecc..6bf8b00dd7 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor( private val presenter: RoomDetailsPresenter, private val room: MatrixRoom, private val analyticsService: AnalyticsService, + private val permalinkBuilder: PermalinkBuilder, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun openRoomMemberList() @@ -84,8 +85,8 @@ class RoomDetailsNode @AssistedInject constructor( private fun onShareRoom(context: Context) { val alias = room.alias ?: room.alternativeAliases.firstOrNull() - val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } - ?: PermalinkBuilder.permalinkForRoomId(room.roomId) + val permalinkResult = alias?.let { permalinkBuilder.permalinkForRoomAlias(it) } + ?: permalinkBuilder.permalinkForRoomId(room.roomId) permalinkResult.onSuccess { permalink -> context.startSharePlainTextIntent( activityResultLauncher = null, @@ -99,7 +100,7 @@ class RoomDetailsNode @AssistedInject constructor( } private fun onShareMember(context: Context, member: RoomMember) { - val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId) + val permalinkResult = permalinkBuilder.permalinkForUser(member.userId) permalinkResult.onSuccess { permalink -> context.startSharePlainTextIntent( activityResultLauncher = null, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index bdf7385661..71cd975e18 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -46,6 +46,7 @@ class RoomMemberDetailsNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val analyticsService: AnalyticsService, + private val permalinkBuilder: PermalinkBuilder, presenterFactory: RoomMemberDetailsPresenter.Factory, ) : Node(buildContext, plugins = plugins) { interface Callback : NodeInputs { @@ -74,7 +75,7 @@ class RoomMemberDetailsNode @AssistedInject constructor( val context = LocalContext.current fun onShareUser() { - val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId) + val permalinkResult = permalinkBuilder.permalinkForUser(inputs.roomMemberId) permalinkResult.onSuccess { permalink -> context.startSharePlainTextIntent( activityResultLauncher = null, diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt index 24296f8798..b0d3f3a339 100644 --- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt @@ -31,9 +31,10 @@ class InviteFriendsUseCase @Inject constructor( private val stringProvider: StringProvider, private val matrixClient: MatrixClient, private val buildMeta: BuildMeta, + private val permalinkBuilder: PermalinkBuilder, ) { fun execute(activity: Activity) { - val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId) + val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId) permalinkResult.fold( onSuccess = { permalink -> val appName = buildMeta.applicationName diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt index e60840768e..ffaabb45ea 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt @@ -25,6 +25,7 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.EventTimelineItem @@ -62,6 +63,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor( private val roomMembershipContentFormatter: RoomMembershipContentFormatter, private val profileChangeContentFormatter: ProfileChangeContentFormatter, private val stateContentFormatter: StateContentFormatter, + private val permalinkParser: PermalinkParser ) : RoomLastMessageFormatter { companion object { // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105 @@ -121,7 +123,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor( return "* $senderDisplayName ${messageType.body}" } is TextMessageType -> { - messageType.toPlainText() + messageType.toPlainText(permalinkParser) } is VideoMessageType -> { sp.getString(CommonStrings.common_video) diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt index a0a17b3465..158eeec20d 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt @@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.timeline.aPollContent import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem @@ -78,7 +79,8 @@ class DefaultRoomLastMessageFormatterTest { sp = AndroidStringProvider(context.resources), roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider), profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), - stateContentFormatter = StateContentFormatter(stringProvider) + stateContentFormatter = StateContentFormatter(stringProvider), + permalinkParser = FakePermalinkParser(), ) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 9304634be2..6219296732 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -34,7 +34,6 @@ anvil { } dependencies { - implementation(projects.appconfig) implementation(projects.libraries.di) implementation(libs.dagger) implementation(projects.libraries.androidutils) @@ -45,7 +44,5 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) - testImplementation(libs.test.robolectric) - testImplementation(projects.tests.testutils) testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt index 4ae7b7dc94..a29a992aca 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -17,40 +17,18 @@ package io.element.android.libraries.matrix.api.permalink import android.net.Uri -import io.element.android.appconfig.MatrixConfiguration /** * Mapping of an input URI to a matrix.to compliant URI. */ -object MatrixToConverter { +interface MatrixToConverter { /** * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url. - * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS]. + * To be successfully converted, URL path should contain one of the [DefaultMatrixToConverter.SUPPORTED_PATHS]. * Examples: * - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org * - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org */ - fun convert(uri: Uri): Uri? { - val uriString = uri.toString() - val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL - - return when { - // URL is already a matrix.to - uriString.startsWith(baseUrl) -> uri - // Web or client url - SUPPORTED_PATHS.any { it in uriString } -> { - val path = SUPPORTED_PATHS.first { it in uriString } - Uri.parse(baseUrl + uriString.substringAfter(path)) - } - // URL is not supported - else -> null - } - } - - private val SUPPORTED_PATHS = listOf( - "/#/room/", - "/#/user/", - "/#/group/" - ) + fun convert(uri: Uri): Uri? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index c46e15db3b..14c29f2de5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -16,70 +16,13 @@ package io.element.android.libraries.matrix.api.permalink -import io.element.android.appconfig.MatrixConfiguration -import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId -object PermalinkBuilder { - private const val ROOM_PATH = "room/" - private const val USER_PATH = "user/" - - private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also { - var baseUrl = it - if (!baseUrl.endsWith("/")) { - baseUrl += "/" - } - if (!baseUrl.endsWith("/#/")) { - baseUrl += "/#/" - } - } - - fun permalinkForUser(userId: UserId): Result { - return if (MatrixPatterns.isUserId(userId.value)) { - val url = buildString { - append(permalinkBaseUrl) - if (!isMatrixTo()) { - append(USER_PATH) - } - append(userId.value) - } - Result.success(url) - } else { - Result.failure(PermalinkBuilderError.InvalidUserId) - } - } - - fun permalinkForRoomAlias(roomAlias: String): Result { - return if (MatrixPatterns.isRoomAlias(roomAlias)) { - Result.success(permalinkForRoomAliasOrId(roomAlias)) - } else { - Result.failure(PermalinkBuilderError.InvalidRoomAlias) - } - } - - fun permalinkForRoomId(roomId: RoomId): Result { - return if (MatrixPatterns.isRoomId(roomId.value)) { - Result.success(permalinkForRoomAliasOrId(roomId.value)) - } else { - Result.failure(PermalinkBuilderError.InvalidRoomId) - } - } - - private fun permalinkForRoomAliasOrId(value: String): String { - val id = escapeId(value) - return buildString { - append(permalinkBaseUrl) - if (!isMatrixTo()) { - append(ROOM_PATH) - } - append(id) - } - } - - private fun escapeId(value: String) = value.replace("/", "%2F") - - private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL) +interface PermalinkBuilder { + fun permalinkForUser(userId: UserId): Result + fun permalinkForRoomAlias(roomAlias: String): Result + fun permalinkForRoomId(roomId: RoomId): Result } sealed class PermalinkBuilderError : Throwable() { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt index 3b90aee1be..463f8fb32d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt @@ -17,11 +17,6 @@ package io.element.android.libraries.matrix.api.permalink import android.net.Uri -import android.net.UrlQuerySanitizer -import io.element.android.libraries.matrix.api.core.MatrixPatterns -import kotlinx.collections.immutable.toImmutableList -import timber.log.Timber -import java.net.URLDecoder /** * This class turns a uri to a [PermalinkData]. @@ -29,121 +24,15 @@ import java.net.URLDecoder * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org) * or client permalinks (e.g. user/@chagai95:matrix.org) */ -object PermalinkParser { +interface PermalinkParser { /** * Turns a uri string to a [PermalinkData]. */ - fun parse(uriString: String): PermalinkData { - val uri = Uri.parse(uriString) - return parse(uri) - } + fun parse(uriString: String): PermalinkData /** * Turns a uri to a [PermalinkData]. * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md */ - fun parse(uri: Uri): PermalinkData { - // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the - // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid - // so convert URI to matrix.to to simplify parsing process - val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) - - // We can't use uri.fragment as it is decoding to early and it will break the parsing - // of parameters that represents url (like signurl) - val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment - if (fragment.isEmpty()) { - return PermalinkData.FallbackLink(uri) - } - val safeFragment = fragment.substringBefore('?') - val viaQueryParameters = fragment.getViaParameters() - - // we are limiting to 2 params - val params = safeFragment - .split(MatrixPatterns.SEP_REGEX) - .filter { it.isNotEmpty() } - .take(2) - - val decodedParams = params - .map { URLDecoder.decode(it, "UTF-8") } - - val identifier = params.getOrNull(0) - val decodedIdentifier = decodedParams.getOrNull(0) - val extraParameter = decodedParams.getOrNull(1) - return when { - identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri) - MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier) - MatrixPatterns.isRoomId(decodedIdentifier) -> { - handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters) - } - MatrixPatterns.isRoomAlias(decodedIdentifier) -> { - PermalinkData.RoomLink( - roomIdOrAlias = decodedIdentifier, - isRoomAlias = true, - eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, - viaParameters = viaQueryParameters.toImmutableList() - ) - } - else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier)) - } - } - - private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List): PermalinkData { - // Can't rely on built in parsing because it's messing around the signurl - val paramList = safeExtractParams(fragment) - val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second - val email = paramList.firstOrNull { it.first == "email" }?.second - return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) { - try { - val signValidUri = Uri.parse(signUrl) - val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`") - val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`") - val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`") - PermalinkData.RoomEmailInviteLink( - roomId = identifier, - email = email!!, - signUrl = signUrl!!, - roomName = paramList.firstOrNull { it.first == "room_name" }?.second, - inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second, - roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second, - roomType = paramList.firstOrNull { it.first == "room_type" }?.second, - identityServer = identityServerHost, - token = token, - privateKey = privateKey - ) - } catch (failure: Throwable) { - Timber.i("## Permalink: Failed to parse permalink $signUrl") - PermalinkData.FallbackLink(uri) - } - } else { - PermalinkData.RoomLink( - roomIdOrAlias = identifier, - isRoomAlias = false, - eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, - viaParameters = viaQueryParameters.toImmutableList() - ) - } - } - - private fun safeExtractParams(fragment: String) = - fragment.substringAfter("?").split('&').mapNotNull { - val splitNameValue = it.split("=") - if (splitNameValue.size == 2) { - Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8")) - } else { - null - } - } - - private fun String.getViaParameters(): List { - return runCatching { - UrlQuerySanitizer(this) - .parameterList - .filter { - it.mParameter == "via" - } - .map { - URLDecoder.decode(it.mValue, "UTF-8") - } - }.getOrDefault(emptyList()) - } + fun parse(uri: Uri): PermalinkData } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index d50bad6696..523ea2fda1 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { } else { debugImplementation(libs.matrix.sdk) } + implementation(projects.appconfig) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) implementation(projects.libraries.network) @@ -52,8 +53,10 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.services.analytics.test) + testImplementation(projects.tests.testutils) testImplementation(libs.coroutines.test) testImplementation(libs.test.turbine) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt new file mode 100644 index 0000000000..002b311600 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt @@ -0,0 +1,61 @@ +/* + * 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. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import android.net.Uri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.MatrixConfiguration +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.permalink.MatrixToConverter +import javax.inject.Inject + +/** + * Mapping of an input URI to a matrix.to compliant URI. + */ +@ContributesBinding(AppScope::class) +class DefaultMatrixToConverter @Inject constructor() : MatrixToConverter { + /** + * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url. + * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS]. + * Examples: + * - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + */ + override fun convert(uri: Uri): Uri? { + val uriString = uri.toString() + val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL + + return when { + // URL is already a matrix.to + uriString.startsWith(baseUrl) -> uri + // Web or client url + SUPPORTED_PATHS.any { it in uriString } -> { + val path = SUPPORTED_PATHS.first { it in uriString } + Uri.parse(baseUrl + uriString.substringAfter(path)) + } + // URL is not supported + else -> null + } + } + + private val SUPPORTED_PATHS = listOf( + "/#/room/", + "/#/user/", + "/#/group/" + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt new file mode 100644 index 0000000000..ae30c94c9c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt @@ -0,0 +1,92 @@ +/* + * 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.impl.permalink + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.MatrixConfiguration +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomId +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.permalink.PermalinkBuilderError +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder { + private val permalinkBaseUrl + get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also { + var baseUrl = it + if (!baseUrl.endsWith("/")) { + baseUrl += "/" + } + if (!baseUrl.endsWith("/#/")) { + baseUrl += "/#/" + } + } + + override fun permalinkForUser(userId: UserId): Result { + return if (MatrixPatterns.isUserId(userId.value)) { + val url = buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(USER_PATH) + } + append(userId.value) + } + Result.success(url) + } else { + Result.failure(PermalinkBuilderError.InvalidUserId) + } + } + + override fun permalinkForRoomAlias(roomAlias: String): Result { + return if (MatrixPatterns.isRoomAlias(roomAlias)) { + Result.success(permalinkForRoomAliasOrId(roomAlias)) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomAlias) + } + } + + override fun permalinkForRoomId(roomId: RoomId): Result { + return if (MatrixPatterns.isRoomId(roomId.value)) { + Result.success(permalinkForRoomAliasOrId(roomId.value)) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomId) + } + } + + private fun permalinkForRoomAliasOrId(value: String): String { + val id = escapeId(value) + return buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(ROOM_PATH) + } + append(id) + } + } + + private fun escapeId(value: String) = value.replace("/", "%2F") + + private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL) + + companion object { + private const val ROOM_PATH = "room/" + private const val USER_PATH = "user/" + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt new file mode 100644 index 0000000000..ab91a89af9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2024 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.impl.permalink + +import android.net.Uri +import android.net.UrlQuerySanitizer +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.permalink.MatrixToConverter +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber +import java.net.URLDecoder +import javax.inject.Inject + +/** + * This class turns a uri to a [PermalinkData]. + * element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks + * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org) + * or client permalinks (e.g. user/@chagai95:matrix.org) + */ +@ContributesBinding(AppScope::class) +class DefaultPermalinkParser @Inject constructor( + private val matrixToConverter: MatrixToConverter +) : PermalinkParser { + /** + * Turns a uri string to a [PermalinkData]. + */ + override fun parse(uriString: String): PermalinkData { + val uri = Uri.parse(uriString) + return parse(uri) + } + + /** + * Turns a uri to a [PermalinkData]. + * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md + */ + override fun parse(uri: Uri): PermalinkData { + // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the + // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid + // so convert URI to matrix.to to simplify parsing process + val matrixToUri = matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) + + // We can't use uri.fragment as it is decoding to early and it will break the parsing + // of parameters that represents url (like signurl) + val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment + if (fragment.isEmpty()) { + return PermalinkData.FallbackLink(uri) + } + val safeFragment = fragment.substringBefore('?') + val viaQueryParameters = fragment.getViaParameters() + + // we are limiting to 2 params + val params = safeFragment + .split(MatrixPatterns.SEP_REGEX) + .filter { it.isNotEmpty() } + .take(2) + + val decodedParams = params + .map { URLDecoder.decode(it, "UTF-8") } + + val identifier = params.getOrNull(0) + val decodedIdentifier = decodedParams.getOrNull(0) + val extraParameter = decodedParams.getOrNull(1) + return when { + identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri) + MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier) + MatrixPatterns.isRoomId(decodedIdentifier) -> { + handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters) + } + MatrixPatterns.isRoomAlias(decodedIdentifier) -> { + PermalinkData.RoomLink( + roomIdOrAlias = decodedIdentifier, + isRoomAlias = true, + eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, + viaParameters = viaQueryParameters.toImmutableList() + ) + } + else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier)) + } + } + + private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List): PermalinkData { + // Can't rely on built in parsing because it's messing around the signurl + val paramList = safeExtractParams(fragment) + val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second + val email = paramList.firstOrNull { it.first == "email" }?.second + return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) { + try { + val signValidUri = Uri.parse(signUrl) + val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`") + val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`") + val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`") + PermalinkData.RoomEmailInviteLink( + roomId = identifier, + email = email!!, + signUrl = signUrl!!, + roomName = paramList.firstOrNull { it.first == "room_name" }?.second, + inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second, + roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second, + roomType = paramList.firstOrNull { it.first == "room_type" }?.second, + identityServer = identityServerHost, + token = token, + privateKey = privateKey + ) + } catch (failure: Throwable) { + Timber.i("## Permalink: Failed to parse permalink $signUrl") + PermalinkData.FallbackLink(uri) + } + } else { + PermalinkData.RoomLink( + roomIdOrAlias = identifier, + isRoomAlias = false, + eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, + viaParameters = viaQueryParameters.toImmutableList() + ) + } + } + + private fun safeExtractParams(fragment: String) = + fragment.substringAfter("?").split('&').mapNotNull { + val splitNameValue = it.split("=") + if (splitNameValue.size == 2) { + Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8")) + } else { + null + } + } + + private fun String.getViaParameters(): List { + return runCatching { + UrlQuerySanitizer(this) + .parameterList + .filter { + it.mParameter == "via" + } + .map { + URLDecoder.decode(it.mValue, "UTF-8") + } + }.getOrDefault(emptyList()) + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt similarity index 71% rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt index cbf1bffc88..7401ac7c2e 100644 --- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.permalink +package io.element.android.libraries.matrix.impl.permalink import android.net.Uri import com.google.common.truth.Truth.assertThat @@ -23,34 +23,34 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class MatrixToConverterTest { +class DefaultMatrixToConverterTest { @Test fun `converting a matrix-to url does nothing`() { val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org") - assertThat(MatrixToConverter.convert(url)).isEqualTo(url) + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(url) } @Test fun `converting a url with a supported room path returns a matrix-to url`() { val url = Uri.parse("https://riot.im/develop/#/room/#element-android:matrix.org") - assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org")) + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org")) } @Test fun `converting a url with a supported user path returns a matrix-to url`() { val url = Uri.parse("https://riot.im/develop/#/user/@test:matrix.org") - assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org")) + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org")) } @Test fun `converting a url with a supported group path returns a matrix-to url`() { val url = Uri.parse("https://riot.im/develop/#/group/+group:matrix.org") - assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org")) + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org")) } @Test fun `converting an unsupported url returns null`() { val url = Uri.parse("https://element.io/") - assertThat(MatrixToConverter.convert(url)).isNull() + assertThat(DefaultMatrixToConverter().convert(url)).isNull() } } diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt similarity index 70% rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt index 2b5b29e084..c861b3105c 100644 --- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.permalink +package io.element.android.libraries.matrix.impl.permalink import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.metadata.withReleaseBehavior @@ -23,18 +23,18 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.tests.testutils.assertThrowsInDebug import org.junit.Test -class PermalinkBuilderTest { +class DefaultPermalinkBuilderTest { fun `building a permalink for an invalid user id throws when verifying the id`() { assertThrowsInDebug { val userId = UserId("some invalid user id") - PermalinkBuilder.permalinkForUser(userId) + DefaultPermalinkBuilder().permalinkForUser(userId) } } fun `building a permalink for an invalid room id throws when verifying the id`() { assertThrowsInDebug { val roomId = RoomId("some invalid room id") - PermalinkBuilder.permalinkForRoomId(roomId) + DefaultPermalinkBuilder().permalinkForRoomId(roomId) } } @@ -42,7 +42,7 @@ class PermalinkBuilderTest { fun `building a permalink for an invalid user id returns failure when not verifying the id`() { withReleaseBehavior { val userId = UserId("some invalid user id") - assertThat(PermalinkBuilder.permalinkForUser(userId).isFailure).isTrue() + assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).isFailure).isTrue() } } @@ -50,31 +50,31 @@ class PermalinkBuilderTest { fun `building a permalink for an invalid room id returns failure when not verifying the id`() { withReleaseBehavior { val roomId = RoomId("some invalid room id") - assertThat(PermalinkBuilder.permalinkForRoomId(roomId).isFailure).isTrue() + assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).isFailure).isTrue() } } @Test fun `building a permalink for an invalid room alias returns failure`() { val roomAlias = "an invalid room alias" - assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).isFailure).isTrue() + assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).isFailure).isTrue() } @Test fun `building a permalink for a valid user id returns a matrix-to url`() { val userId = UserId("@user:matrix.org") - assertThat(PermalinkBuilder.permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org") + assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org") } @Test fun `building a permalink for a valid room id returns a matrix-to url`() { val roomId = RoomId("!aBCdEFG1234:matrix.org") - assertThat(PermalinkBuilder.permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org") + assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org") } @Test fun `building a permalink for a valid room alias returns a matrix-to url`() { val roomAlias = "#room:matrix.org" - assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org") + assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org") } } diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt similarity index 70% rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt index 66cdaf88ee..1e9e3bc2dc 100644 --- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 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. @@ -14,44 +14,60 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.permalink +package io.element.android.libraries.matrix.impl.permalink import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData import kotlinx.collections.immutable.persistentListOf import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class PermalinkParserTest { +class DefaultPermalinkParserTest { @Test fun `parsing an invalid url returns a fallback link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://element.io" - assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) + assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) } @Test fun `parsing an invalid url with the right path but no content returns a fallback link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/user" - assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) + assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) } @Test fun `parsing an invalid url with the right path but empty content returns a fallback link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/user/" - assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) + assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) } @Test fun `parsing an invalid url with the right path but invalid content returns a fallback link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/user/some%20user!" - assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) + assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) } @Test fun `parsing a valid user url returns a user link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/user/@test:matrix.org" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.UserLink( userId = "@test:matrix.org" ) @@ -60,8 +76,11 @@ class PermalinkParserTest { @Test fun `parsing a valid room id url returns a room link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/!aBCD1234:matrix.org" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomLink( roomIdOrAlias = "!aBCD1234:matrix.org", isRoomAlias = false, @@ -73,8 +92,11 @@ class PermalinkParserTest { @Test fun `parsing a valid room id with event id url returns a room link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomLink( roomIdOrAlias = "!aBCD1234:matrix.org", isRoomAlias = false, @@ -86,8 +108,11 @@ class PermalinkParserTest { @Test fun `parsing a valid room id with and invalid event id url returns a room link with no event id`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/1234567890abcdef:matrix.org" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomLink( roomIdOrAlias = "!aBCD1234:matrix.org", isRoomAlias = false, @@ -99,8 +124,11 @@ class PermalinkParserTest { @Test fun `parsing a valid room id with event id and via parameters url returns a room link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org?via=matrix.org&via=matrix.com" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomLink( roomIdOrAlias = "!aBCD1234:matrix.org", isRoomAlias = false, @@ -112,8 +140,11 @@ class PermalinkParserTest { @Test fun `parsing a valid room alias url returns a room link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/#element-android:matrix.org" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomLink( roomIdOrAlias = "#element-android:matrix.org", isRoomAlias = true, @@ -125,6 +156,9 @@ class PermalinkParserTest { @Test fun `parsing a url with an invalid signurl returns a fallback link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) // This url has no private key val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" + "?email=testuser%40element.io" + @@ -135,11 +169,14 @@ class PermalinkParserTest { "&guest_access_token=" + "&guest_user_id=" + "&room_type=" - assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) + assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) } @Test fun `parsing a url with signurl returns a room email invite link`() { + val sut = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" + "?email=testuser%40element.io" + "&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3Da_token%26private_key%3Da_private_key" + @@ -149,7 +186,7 @@ class PermalinkParserTest { "&guest_access_token=" + "&guest_user_id=" + "&room_type=" - assertThat(PermalinkParser.parse(url)).isEqualTo( + assertThat(sut.parse(url)).isEqualTo( PermalinkData.RoomEmailInviteLink( roomId = "!aBCDEF12345:matrix.org", email = "testuser@element.io", diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt new file mode 100644 index 0000000000..4e6d3375e1 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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.test.permalink + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder + +class FakePermalinkBuilder( + private val result: () -> Result = { Result.failure(Exception("Not implemented")) } +) : PermalinkBuilder { + override fun permalinkForUser(userId: UserId): Result { + return result() + } + + override fun permalinkForRoomAlias(roomAlias: String): Result { + return result() + } + + override fun permalinkForRoomId(roomId: RoomId): Result { + return result() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt new file mode 100644 index 0000000000..5c6935d76d --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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.test.permalink + +import android.net.Uri +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser + +class FakePermalinkParser( + private var result: () -> PermalinkData = { throw Exception("Not implemented") } +) : PermalinkParser { + fun givenResult(result: PermalinkData) { + this.result = { result } + } + + override fun parse(uriString: String): PermalinkData { + return result() + } + + override fun parse(uri: Uri): PermalinkData { + TODO("Not yet implemented") + } +} diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 669f9c9634..fa7a946959 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -47,4 +47,5 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.test.robolectric) + testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt index b3c6a0bf86..36ae94d7f7 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -29,9 +29,13 @@ import org.jsoup.nodes.Document * * This will also make sure mentions are prefixed with `@`. * + * @param permalinkParser the parser to use to parse the mentions. * @param prefix if not null, the prefix will be inserted at the beginning of the message. */ -fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? { +fun FormattedBody.toHtmlDocument( + permalinkParser: PermalinkParser, + prefix: String? = null, +): Document? { return takeIf { it.format == MessageFormat.HTML }?.body // Trim whitespace at the end to avoid having wrong rendering of the message. // We don't trim the start in case it's used as indentation. @@ -44,17 +48,20 @@ fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? { } // Prepend `@` to mentions - fixMentions(dom) + fixMentions(dom, permalinkParser) dom } } -private fun fixMentions(dom: Document) { +private fun fixMentions( + dom: Document, + permalinkParser: PermalinkParser, +) { val links = dom.getElementsByTag("a") links.forEach { if (it.hasAttr("href")) { - val link = PermalinkParser.parse(it.attr("href")) + val link = permalinkParser.parse(it.attr("href")) if (link is PermalinkData.UserLink && !it.text().startsWith("@")) { it.prependText("@") } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt index 0cea2d7ae2..1eb581af65 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.ui.messages +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -29,15 +30,24 @@ import org.jsoup.select.NodeVisitor * Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting. * If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead. */ -fun TextMessageType.toPlainText() = formatted?.toPlainText() ?: body +fun TextMessageType.toPlainText( + permalinkParser: PermalinkParser, +) = formatted?.toPlainText(permalinkParser) ?: body /** * Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting. * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`. + * @param permalinkParser the parser to use to parse the mentions. * @param prefix if not null, the prefix will be inserted at the beginning of the message. */ -fun FormattedBody.toPlainText(prefix: String? = null): String? { - return this.toHtmlDocument(prefix)?.toPlainText() +fun FormattedBody.toPlainText( + permalinkParser: PermalinkParser, + prefix: String? = null, +): String? { + return this.toHtmlDocument( + permalinkParser = permalinkParser, + prefix = prefix, + )?.toPlainText() } /** diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt index 704b87c593..ab83f77e66 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt @@ -16,9 +16,13 @@ package io.element.android.libraries.matrixui.messages +import android.net.Uri import com.google.common.truth.Truth.assertThat +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.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import org.junit.Test import org.junit.runner.RunWith @@ -33,7 +37,7 @@ class ToHtmlDocumentTest { body = "Hello world" ) - val document = body.toHtmlDocument() + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) assertThat(document).isNull() } @@ -45,7 +49,7 @@ class ToHtmlDocumentTest { body = "

Hello world

" ) - val document = body.toHtmlDocument() + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) assertThat(document).isNotNull() assertThat(document?.text()).isEqualTo("Hello world") } @@ -57,7 +61,10 @@ class ToHtmlDocumentTest { body = "

Hello world

" ) - val document = body.toHtmlDocument(prefix = "@Jorge:") + val document = body.toHtmlDocument( + permalinkParser = FakePermalinkParser(), + prefix = "@Jorge:" + ) assertThat(document).isNotNull() assertThat(document?.text()).isEqualTo("@Jorge: Hello world") } @@ -69,7 +76,13 @@ class ToHtmlDocumentTest { body = "Hey Alice!" ) - val document = body.toHtmlDocument() + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.UserLink("@alice:matrix.org") + } + + override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented") + }) assertThat(document?.text()).isEqualTo("Hey @Alice!") } @@ -80,7 +93,13 @@ class ToHtmlDocumentTest { body = "Hey @Alice!" ) - val document = body.toHtmlDocument() + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.UserLink("@alice:matrix.org") + } + + override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented") + }) assertThat(document?.text()).isEqualTo("Hey @Alice!") } @@ -91,7 +110,13 @@ class ToHtmlDocumentTest { body = "Hey Alice!" ) - val document = body.toHtmlDocument() + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.FallbackLink(uri = Uri.parse("https://matrix.org")) + } + + override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented") + }) assertThat(document?.text()).isEqualTo("Hey Alice!") } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt index 7c61075584..74e43a9e39 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt @@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.messages.toPlainText import org.jsoup.Jsoup import org.junit.Test @@ -59,7 +60,7 @@ class ToPlainTextTest {
""".trimIndent() ) - assertThat(formattedBody.toPlainText()).isEqualTo( + assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( """ Hello world • This is an unordered list. @@ -79,7 +80,7 @@ class ToPlainTextTest {
""".trimIndent() ) - assertThat(formattedBody.toPlainText()).isNull() + assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull() } @Test @@ -96,7 +97,7 @@ class ToPlainTextTest { """.trimIndent() ) ) - assertThat(messageType.toPlainText()).isEqualTo( + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( """ Hello world • This is an unordered list. @@ -119,6 +120,6 @@ class ToPlainTextTest { """.trimIndent() ) ) - assertThat(messageType.toPlainText()).isEqualTo("This is the fallback text") + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text") } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index 3b2ac19313..8a285e3b32 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -68,6 +69,7 @@ class NotifiableEventResolver @Inject constructor( private val matrixClientProvider: MatrixClientProvider, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, @ApplicationContext private val context: Context, + private val permalinkParser: PermalinkParser, ) { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session @@ -252,7 +254,7 @@ class NotifiableEventResolver @Inject constructor( is ImageMessageType -> messageType.body is StickerMessageType -> messageType.body is NoticeMessageType -> messageType.body - is TextMessageType -> messageType.toPlainText() + is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser) is VideoMessageType -> messageType.body is LocationMessageType -> messageType.body is OtherMessageType -> messageType.body diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt index 4e491a0def..b2f954091e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt @@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -549,6 +550,7 @@ class NotifiableEventResolverTest { matrixClientProvider = matrixClientProvider, notificationMediaRepoFactory = notificationMediaRepoFactory, context = context, + permalinkParser = FakePermalinkParser(), ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 91c8bd9666..caa5e9f96b 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -63,6 +63,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo @@ -98,6 +100,7 @@ import kotlin.time.Duration.Companion.seconds fun TextComposer( state: RichTextEditorState, voiceMessageState: VoiceMessageState, + permalinkParser: PermalinkParser, composerMode: MessageComposerMode, enableTextFormatting: Boolean, enableVoiceMessages: Boolean, @@ -152,7 +155,10 @@ fun TextComposer( val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { @Composable { - val mentionSpanProvider = rememberMentionSpanProvider(currentUserId) + val mentionSpanProvider = rememberMentionSpanProvider( + currentUserId = currentUserId, + permalinkParser = permalinkParser, + ) TextInput( state = state, subcomposing = subcomposing, @@ -907,6 +913,10 @@ private fun ATextComposer( state = richTextEditorState, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented") + override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented") + }, composerMode = composerMode, enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index f7b28d65e8..e50b0fc16a 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer.mentions import android.graphics.Color import android.graphics.Typeface +import android.net.Uri import android.view.ViewGroup import android.widget.TextView import androidx.compose.foundation.layout.PaddingValues @@ -42,10 +43,12 @@ import io.element.android.libraries.designsystem.theme.mentionPillText import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import kotlinx.collections.immutable.persistentListOf @Stable class MentionSpanProvider( private val currentSessionId: SessionId, + private val permalinkParser: PermalinkParser, private var currentUserTextColor: Int = 0, private var currentUserBackgroundColor: Int = Color.WHITE, private var otherTextColor: Int = 0, @@ -73,7 +76,7 @@ class MentionSpanProvider( } fun getMentionSpanFor(text: String, url: String): MentionSpan { - val permalinkData = PermalinkParser.parse(url) + val permalinkData = permalinkParser.parse(url) val (startPaddingPx, endPaddingPx) = paddingValuesPx.value return when { permalinkData is PermalinkData.UserLink -> { @@ -112,9 +115,15 @@ class MentionSpanProvider( } @Composable -fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider { +fun rememberMentionSpanProvider( + currentUserId: SessionId, + permalinkParser: PermalinkParser, +): MentionSpanProvider { val provider = remember(currentUserId) { - MentionSpanProvider(currentUserId) + MentionSpanProvider( + currentSessionId = currentUserId, + permalinkParser = permalinkParser, + ) } provider.setup() return provider @@ -123,7 +132,26 @@ fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider { @PreviewsDayNight @Composable internal fun MentionSpanPreview() { - val provider = rememberMentionSpanProvider(SessionId("@me:matrix.org")) + val provider = rememberMentionSpanProvider( + currentUserId = SessionId("@me:matrix.org"), + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return when (uriString) { + "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink("@me:matrix.org") + "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink("@other:matrix.org") + "https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink( + roomIdOrAlias = "#room:matrix.org", + isRoomAlias = true, + eventId = null, + viaParameters = persistentListOf(), + ) + else -> TODO() + } + } + + override fun parse(uri: Uri): PermalinkData = TODO() + }, + ) ElementPreview { provider.setup() diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt index c8281cca0e..475b879895 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt @@ -18,9 +18,12 @@ package io.element.android.libraries.textcomposer.impl.mentions import android.graphics.Color import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -35,8 +38,10 @@ class MentionSpanProviderTest { private val otherColor = Color.BLUE private val currentUserId = A_SESSION_ID + private val permalinkParser = FakePermalinkParser() private val mentionSpanProvider = MentionSpanProvider( currentSessionId = currentUserId, + permalinkParser = permalinkParser, currentUserBackgroundColor = myUserColor, currentUserTextColor = myUserColor, otherBackgroundColor = otherColor, @@ -45,6 +50,7 @@ class MentionSpanProviderTest { @Test fun `getting mention span for current user should return a MentionSpan with custom colors`() { + permalinkParser.givenResult(PermalinkData.UserLink(currentUserId.value)) val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}") assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor) assertThat(mentionSpan.textColor).isEqualTo(myUserColor) @@ -52,6 +58,7 @@ class MentionSpanProviderTest { @Test fun `getting mention span for other user should return a MentionSpan with normal colors`() { + permalinkParser.givenResult(PermalinkData.UserLink("@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) @@ -59,6 +66,14 @@ class MentionSpanProviderTest { @Test fun `getting mention span for a room should return a MentionSpan with normal colors`() { + permalinkParser.givenResult( + PermalinkData.RoomLink( + roomIdOrAlias = "#room:matrix.org", + isRoomAlias = true, + eventId = null, + viaParameters = persistentListOf(), + ) + ) val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor) @@ -66,6 +81,14 @@ class MentionSpanProviderTest { @Test fun `getting mention span for @room should return a MentionSpan with normal colors`() { + permalinkParser.givenResult( + PermalinkData.RoomLink( + roomIdOrAlias = "#", + isRoomAlias = true, + eventId = null, + viaParameters = persistentListOf(), + ) + ) val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor)