Merge pull request #2639 from element-hq/feature/bma/hideCallMemberEvents

Hide call member events
This commit is contained in:
Benoit Marty 2024-04-02 16:14:11 +02:00 committed by GitHub
commit 3edbb29eb8
58 changed files with 843 additions and 343 deletions

View file

@ -14,16 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("java-library") id("io.element.android-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
java { android {
sourceCompatibility = JavaVersion.VERSION_17 namespace = "io.element.android.appconfig"
targetCompatibility = JavaVersion.VERSION_17
} }
anvil { anvil {
@ -33,4 +30,5 @@ anvil {
dependencies { dependencies {
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
} }

View file

@ -16,6 +16,29 @@
package io.element.android.appconfig package io.element.android.appconfig
import io.element.android.libraries.matrix.api.room.StateEventType
object TimelineConfig { object TimelineConfig {
const val MAX_READ_RECEIPT_TO_DISPLAY = 3 const val MAX_READ_RECEIPT_TO_DISPLAY = 3
/**
* Event types that will be filtered out from the timeline (i.e. not displayed).
*/
val excludedEvents = listOf(
StateEventType.CALL_MEMBER,
StateEventType.ROOM_ALIASES,
StateEventType.ROOM_CANONICAL_ALIAS,
StateEventType.ROOM_GUEST_ACCESS,
StateEventType.ROOM_HISTORY_VISIBILITY,
StateEventType.ROOM_JOIN_RULES,
StateEventType.ROOM_PINNED_EVENTS,
StateEventType.ROOM_POWER_LEVELS,
StateEventType.ROOM_SERVER_ACL,
StateEventType.ROOM_TOMBSTONE,
StateEventType.SPACE_CHILD,
StateEventType.SPACE_PARENT,
StateEventType.POLICY_RULE_ROOM,
StateEventType.POLICY_RULE_SERVER,
StateEventType.POLICY_RULE_USER,
)
} }

1
changelog.d/2625.bugfix Normal file
View file

@ -0,0 +1 @@
Hide Event org.matrix.msc3401.call.member on the timeline.

View file

@ -16,9 +16,12 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node 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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.core.bool.orFalse
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
presenterFactory: MessagesPresenter.Factory, presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this) private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull() private val callback = plugins<Callback>().firstOrNull()
@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) { private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId) callback?.onUserDataClicked(userId)
} }
private fun onLinkClicked(
context: Context,
url: String,
) {
when (val permalink = permalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
callback?.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) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo) callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
} }
@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked, onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments, onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked, onUserDataClicked = this::onUserDataClicked,
onLinkClicked = { onLinkClicked(context, it) },
onSendLocationClicked = this::onSendLocationClicked, onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked, onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked, onJoinCallClicked = this::onJoinCallClicked,

View file

@ -119,6 +119,7 @@ fun MessagesView(
onRoomDetailsClicked: () -> Unit, onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Boolean, onEventClicked: (event: TimelineItem.Event) -> Boolean,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit, onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit, onCreatePollClicked: () -> Unit,
@ -213,6 +214,7 @@ fun MessagesView(
onMessageClicked = ::onMessageClicked, onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked, onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onTimestampClicked = { event -> onTimestampClicked = { event ->
if (event.localSendState is LocalEventSendState.SendingFailed) { if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
@ -313,6 +315,7 @@ private fun MessagesViewContent(
state: MessagesState, state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
@ -386,6 +389,7 @@ private fun MessagesViewContent(
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked, onReactionLongClicked = onReactionLongClicked,
@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onEventClicked = { false }, onEventClicked = { false },
onPreviewAttachments = {}, onPreviewAttachments = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
onJoinCallClicked = {}, onJoinCallClicked = {},

View file

@ -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.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder, private val currentSessionIdHolder: CurrentSessionIdHolder,
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor(
} }
is MentionSuggestion.Member -> { is MentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value 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) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
} }
@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor(
return MessageComposerState( return MessageComposerState(
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,

View file

@ -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.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestion 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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
@Stable @Stable
data class MessageComposerState( data class MessageComposerState(
val richTextEditorState: RichTextEditorState, val richTextEditorState: RichTextEditorState,
val permalinkParser: PermalinkParser,
val isFullScreen: Boolean, val isFullScreen: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,

View file

@ -16,9 +16,12 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.mentions.MentionSuggestion 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.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.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -43,6 +46,10 @@ fun aMessageComposerState(
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(), memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO()
override fun parse(uri: Uri): PermalinkData = TODO()
},
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
mode = mode, mode = mode,
showTextFormatting = showTextFormatting, showTextFormatting = showTextFormatting,

View file

@ -109,6 +109,7 @@ internal fun MessageComposerView(
modifier = modifier, modifier = modifier,
state = state.richTextEditorState, state = state.richTextEditorState,
voiceMessageState = voiceMessageState.voiceMessageState, voiceMessageState = voiceMessageState.voiceMessageState,
permalinkParser = state.permalinkParser,
subcomposing = subcomposing, subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus, onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage, onSendMessage = ::sendMessage,

View file

@ -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.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter import io.element.android.wysiwyg.compose.StyledHtmlConverter
@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider { class DefaultHtmlConverterProvider @Inject constructor(
private val permalinkParser: PermalinkParser,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId) val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val context = LocalContext.current val context = LocalContext.current

View file

@ -81,6 +81,7 @@ fun TimelineView(
typingNotificationState: TypingNotificationState, typingNotificationState: TypingNotificationState,
roomName: String?, roomName: String?,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
@ -140,6 +141,7 @@ fun TimelineView(
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
onLinkClicked = onLinkClicked,
inReplyToClick = ::inReplyToClicked, inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked, onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked, onReactionLongClick = onReactionLongClicked,
@ -276,6 +278,7 @@ internal fun TimelineViewPreview(
onMessageClicked = {}, onMessageClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onMessageLongClicked = {}, onMessageLongClicked = {},
onReactionClicked = { _, _ -> }, onReactionClicked = { _, _ -> },
onReactionLongClicked = { _, _ -> }, onReactionLongClicked = { _, _ -> },

View file

@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow(
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -50,7 +49,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -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.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo 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.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -128,6 +122,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClicked: (String) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
@ -151,13 +146,6 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId) 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()) { Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) { if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -203,7 +191,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -222,7 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent(
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
onMentionClicked: (Mention) -> Unit, onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent(
onTimestampClicked = { onTimestampClicked = {
onTimestampClicked(event) onTimestampClicked(event)
}, },
onMentionClicked = onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -429,7 +417,7 @@ private fun MessageEventBubbleContent(
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit, onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter") @SuppressLint("ModifierParameter")
// need to rename this modifier to prevent linter false positives // need to rename this modifier to prevent linter false positives
@ -530,7 +518,6 @@ private fun MessageEventBubbleContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canShrinkContent: Boolean = false, canShrinkContent: Boolean = false,
) { ) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier val timestampLayoutModifier: Modifier
val contentModifier: Modifier val contentModifier: Modifier
when { when {
@ -566,20 +553,7 @@ private fun MessageEventBubbleContent(
) { onContentLayoutChanged -> ) { onContentLayoutChanged ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
onLinkClicked = { url -> onLinkClicked = onLinkClicked,
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)
}
}
},
eventSink = eventSink, eventSink = eventSink,
onContentLayoutChanged = onContentLayoutChanged, onContentLayoutChanged = onContentLayoutChanged,
modifier = contentModifier modifier = contentModifier

View file

@ -51,6 +51,7 @@ fun TimelineItemGroupedEventsRow(
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -78,6 +79,7 @@ fun TimelineItemGroupedEventsRow(
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -102,6 +104,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -135,6 +138,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -175,6 +179,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
@ -200,6 +205,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -36,6 +36,7 @@ internal fun TimelineItemRow(
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState, sessionState: SessionState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -79,6 +80,7 @@ internal fun TimelineItemRow(
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -103,6 +105,7 @@ internal fun TimelineItemRow(
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -67,6 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val fileExtensionExtractor: FileExtensionExtractor, private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
) { ) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent { suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) { return when (val messageType = content.type) {
@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor(
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"), htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisplayName",
),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(), formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )

View file

@ -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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient 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.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails 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 contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val permalinkParser: PermalinkParser,
) { ) {
suspend fun create( suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(), inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(), isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo, debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin, origin = currentTimelineItem.event.origin,

View file

@ -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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent 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.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@ -34,7 +35,9 @@ data class InReplyToDetails(
val textContent: String?, val textContent: String?,
) )
fun InReplyTo.map() = when (this) { fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
is InReplyTo.Ready -> InReplyToDetails( is InReplyTo.Ready -> InReplyToDetails(
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) {
textContent = when (content) { textContent = when (content) {
is MessageContent -> { is MessageContent -> {
val messageContent = content as MessageContent val messageContent = content as MessageContent
(messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
} }
is StickerContent -> { is StickerContent -> {
val stickerContent = content as StickerContent val stickerContent = content as StickerContent

View file

@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview(
onEventClicked = { false }, onEventClicked = { false },
onPreviewAttachments = {}, onPreviewAttachments = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
onJoinCallClicked = {}, onJoinCallClicked = {},

View file

@ -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.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta 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.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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
@ -725,6 +727,8 @@ class MessagesPresenterTest {
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory, permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
) )
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this, this,

View file

@ -478,6 +478,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(), onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClicked: () -> Unit = EnsureNeverCalled(), onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
onCreatePollClicked: () -> Unit = EnsureNeverCalled(), onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
@ -492,6 +493,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onRoomDetailsClicked = onRoomDetailsClicked, onRoomDetailsClicked = onRoomDetailsClicked,
onEventClicked = onEventClicked, onEventClicked = onEventClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onPreviewAttachments = onPreviewAttachments, onPreviewAttachments = onPreviewAttachments,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,

View file

@ -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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(),
), ),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory( stickerFactory = TimelineItemContentStickerFactory(
@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
), ),
matrixClient = matrixClient, matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(), lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
permalinkParser = FakePermalinkParser(),
), ),
virtualItemFactory = TimelineItemVirtualFactory( virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -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.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention 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_ID_4
import io.element.android.libraries.matrix.test.A_USER_NAME 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.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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -805,7 +808,14 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - insertMention`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -941,6 +951,7 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, coroutineScope,
room, room,
@ -955,6 +966,8 @@ class MessageComposerPresenterTest {
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,
) )
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID 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.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { 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() val exception = runCatching { provider.provide() }.exceptionOrNull()
@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider() val provider = DefaultHtmlConverterProvider(
permalinkParser = FakePermalinkParser(),
)
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID) provider.Update(currentUserId = A_USER_ID)

View file

@ -45,6 +45,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
roomName = null, roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(), onUserDataClicked = EnsureNeverCalledWithParam(),
onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(),
@ -72,6 +73,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
roomName = null, roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(), onUserDataClicked = EnsureNeverCalledWithParam(),
onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(),

View file

@ -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.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType 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.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.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
permalinkParser = FakePermalinkParser(),
) )
private fun createStickerContent( private fun createStickerContent(

View file

@ -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.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Test import org.junit.Test
class InReplyToDetailTest { class InReplyToDetailTest {
@Test @Test
fun `map - with a not ready InReplyTo does not work`() { fun `map - with a not ready InReplyTo does not work`() {
assertThat(InReplyTo.Pending.map()).isNull() assertThat(
assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull() InReplyTo.Pending.map(
assertThat(InReplyTo.Error.map()).isNull() permalinkParser = FakePermalinkParser()
)
).isNull()
assertThat(
InReplyTo.NotLoaded(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
).isNull()
assertThat(
InReplyTo.Error.map(
permalinkParser = FakePermalinkParser()
)
).isNull()
} }
@Test @Test
@ -48,7 +61,9 @@ class InReplyToDetailTest {
change = MembershipChange.INVITED, change = MembershipChange.INVITED,
) )
) )
val inReplyToDetails = inReplyTo.map() val inReplyToDetails = inReplyTo.map(
permalinkParser = FakePermalinkParser()
)
assertThat(inReplyToDetails).isNotNull() assertThat(inReplyToDetails).isNotNull()
assertThat(inReplyToDetails?.textContent).isNull() 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 @Test
@ -95,6 +114,10 @@ class InReplyToDetailTest {
) )
) )
) )
assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**") assertThat(
inReplyTo.map(
permalinkParser = FakePermalinkParser()
)?.textContent
).isEqualTo("**Hello!**")
} }
} }

View file

@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter, private val presenter: RoomDetailsPresenter,
private val room: MatrixRoom, private val room: MatrixRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberList() fun openRoomMemberList()
@ -84,8 +85,8 @@ class RoomDetailsNode @AssistedInject constructor(
private fun onShareRoom(context: Context) { private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull() val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } val permalinkResult = alias?.let { permalinkBuilder.permalinkForRoomAlias(it) }
?: PermalinkBuilder.permalinkForRoomId(room.roomId) ?: permalinkBuilder.permalinkForRoomId(room.roomId)
permalinkResult.onSuccess { permalink -> permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent( context.startSharePlainTextIntent(
activityResultLauncher = null, activityResultLauncher = null,
@ -99,7 +100,7 @@ class RoomDetailsNode @AssistedInject constructor(
} }
private fun onShareMember(context: Context, member: RoomMember) { private fun onShareMember(context: Context, member: RoomMember) {
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId) val permalinkResult = permalinkBuilder.permalinkForUser(member.userId)
permalinkResult.onSuccess { permalink -> permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent( context.startSharePlainTextIntent(
activityResultLauncher = null, activityResultLauncher = null,

View file

@ -46,6 +46,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: RoomMemberDetailsPresenter.Factory, presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : NodeInputs { interface Callback : NodeInputs {
@ -74,7 +75,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
val context = LocalContext.current val context = LocalContext.current
fun onShareUser() { fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId) val permalinkResult = permalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink -> permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent( context.startSharePlainTextIntent(
activityResultLauncher = null, activityResultLauncher = null,

View file

@ -31,9 +31,10 @@ class InviteFriendsUseCase @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val permalinkBuilder: PermalinkBuilder,
) { ) {
fun execute(activity: Activity) { fun execute(activity: Activity) {
val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId) val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId)
permalinkResult.fold( permalinkResult.fold(
onSuccess = { permalink -> onSuccess = { permalink ->
val appName = buildMeta.applicationName val appName = buildMeta.applicationName

View file

@ -25,6 +25,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode 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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem 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 roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter, private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter, private val stateContentFormatter: StateContentFormatter,
private val permalinkParser: PermalinkParser
) : RoomLastMessageFormatter { ) : RoomLastMessageFormatter {
companion object { companion object {
// Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105 // 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}" return "* $senderDisplayName ${messageType.body}"
} }
is TextMessageType -> { is TextMessageType -> {
messageType.toPlainText() messageType.toPlainText(permalinkParser)
} }
is VideoMessageType -> { is VideoMessageType -> {
sp.getString(CommonStrings.common_video) sp.getString(CommonStrings.common_video)

View file

@ -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.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.A_USER_ID 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.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.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@ -78,7 +79,8 @@ class DefaultRoomLastMessageFormatterTest {
sp = AndroidStringProvider(context.resources), sp = AndroidStringProvider(context.resources),
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider), roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider) stateContentFormatter = StateContentFormatter(stringProvider),
permalinkParser = FakePermalinkParser(),
) )
} }

View file

@ -34,7 +34,6 @@ anvil {
} }
dependencies { dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@ -45,7 +44,5 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
} }

View file

@ -17,40 +17,17 @@
package io.element.android.libraries.matrix.api.permalink package io.element.android.libraries.matrix.api.permalink
import android.net.Uri import android.net.Uri
import io.element.android.appconfig.MatrixConfiguration
/** /**
* Mapping of an input URI to a matrix.to compliant URI. * 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. * 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: * Examples:
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org * - 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://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 * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
*/ */
fun convert(uri: Uri): Uri? { 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/"
)
} }

View file

@ -16,70 +16,13 @@
package io.element.android.libraries.matrix.api.permalink 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
object PermalinkBuilder { interface PermalinkBuilder {
private const val ROOM_PATH = "room/" fun permalinkForUser(userId: UserId): Result<String>
private const val USER_PATH = "user/" fun permalinkForRoomAlias(roomAlias: String): Result<String>
fun permalinkForRoomId(roomId: RoomId): Result<String>
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<String> {
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<String> {
return if (MatrixPatterns.isRoomAlias(roomAlias)) {
Result.success(permalinkForRoomAliasOrId(roomAlias))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomAlias)
}
}
fun permalinkForRoomId(roomId: RoomId): Result<String> {
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)
} }
sealed class PermalinkBuilderError : Throwable() { sealed class PermalinkBuilderError : Throwable() {

View file

@ -17,11 +17,6 @@
package io.element.android.libraries.matrix.api.permalink package io.element.android.libraries.matrix.api.permalink
import android.net.Uri 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]. * 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 matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
* or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org) * or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
*/ */
object PermalinkParser { interface PermalinkParser {
/** /**
* Turns a uri string to a [PermalinkData]. * Turns a uri string to a [PermalinkData].
*/ */
fun parse(uriString: String): PermalinkData { fun parse(uriString: String): PermalinkData
val uri = Uri.parse(uriString)
return parse(uri)
}
/** /**
* Turns a uri to a [PermalinkData]. * Turns a uri to a [PermalinkData].
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/ */
fun parse(uri: Uri): PermalinkData { 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<String>): 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<String> {
return runCatching {
UrlQuerySanitizer(this)
.parameterList
.filter {
it.mParameter == "via"
}
.map {
URLDecoder.decode(it.mValue, "UTF-8")
}
}.getOrDefault(emptyList())
}
} }

View file

@ -36,6 +36,7 @@ dependencies {
} else { } else {
debugImplementation(libs.matrix.sdk) debugImplementation(libs.matrix.sdk)
} }
implementation(projects.appconfig)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.network) implementation(projects.libraries.network)
@ -52,8 +53,10 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -54,6 +55,7 @@ import io.element.android.libraries.matrix.impl.room.MatrixRoomInfoMapper
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
@ -97,7 +99,6 @@ import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.StateEventType
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
@ -229,24 +230,15 @@ class RustMatrixClient(
), ),
) )
private val eventFilters = TimelineEventTypeFilter.exclude( private val eventFilters = TimelineConfig.excludedEvents
listOf( .takeIf { it.isNotEmpty() }
StateEventType.ROOM_ALIASES, ?.let { listStateEventType ->
StateEventType.ROOM_CANONICAL_ALIAS, TimelineEventTypeFilter.exclude(
StateEventType.ROOM_GUEST_ACCESS, listStateEventType.map { stateEventType ->
StateEventType.ROOM_HISTORY_VISIBILITY, FilterTimelineEventType.State(stateEventType.map())
StateEventType.ROOM_JOIN_RULES, }
StateEventType.ROOM_PINNED_EVENTS, )
StateEventType.ROOM_POWER_LEVELS, }
StateEventType.ROOM_SERVER_ACL,
StateEventType.ROOM_TOMBSTONE,
StateEventType.SPACE_CHILD,
StateEventType.SPACE_PARENT,
StateEventType.POLICY_RULE_ROOM,
StateEventType.POLICY_RULE_SERVER,
StateEventType.POLICY_RULE_USER,
).map(FilterTimelineEventType::State)
)
override val mediaLoader: MatrixMediaLoader = RustMediaLoader( override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
baseCacheDirectory = baseCacheDirectory, baseCacheDirectory = baseCacheDirectory,

View file

@ -0,0 +1,63 @@
/*
* 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
}
}
companion object {
val SUPPORTED_PATHS = listOf(
"/#/room/",
"/#/user/",
"/#/group/"
)
}
}

View file

@ -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<String> {
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<String> {
return if (MatrixPatterns.isRoomAlias(roomAlias)) {
Result.success(permalinkForRoomAliasOrId(roomAlias))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomAlias)
}
}
override fun permalinkForRoomId(roomId: RoomId): Result<String> {
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/"
}
}

View file

@ -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. <clientPermalinkBaseUrl>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<String>): 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<String> {
return runCatching {
UrlQuerySanitizer(this)
.parameterList
.filter {
it.mParameter == "via"
}
.map {
URLDecoder.decode(it.mValue, "UTF-8")
}
}.getOrDefault(emptyList())
}
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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 android.net.Uri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@ -23,34 +23,34 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class MatrixToConverterTest { class DefaultMatrixToConverterTest {
@Test @Test
fun `converting a matrix-to url does nothing`() { fun `converting a matrix-to url does nothing`() {
val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org") val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
assertThat(MatrixToConverter.convert(url)).isEqualTo(url) assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(url)
} }
@Test @Test
fun `converting a url with a supported room path returns a matrix-to url`() { 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") 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 @Test
fun `converting a url with a supported user path returns a matrix-to url`() { 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") 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 @Test
fun `converting a url with a supported group path returns a matrix-to url`() { 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") 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 @Test
fun `converting an unsupported url returns null`() { fun `converting an unsupported url returns null`() {
val url = Uri.parse("https://element.io/") val url = Uri.parse("https://element.io/")
assertThat(MatrixToConverter.convert(url)).isNull() assertThat(DefaultMatrixToConverter().convert(url)).isNull()
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.metadata.withReleaseBehavior 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 io.element.android.tests.testutils.assertThrowsInDebug
import org.junit.Test import org.junit.Test
class PermalinkBuilderTest { class DefaultPermalinkBuilderTest {
fun `building a permalink for an invalid user id throws when verifying the id`() { fun `building a permalink for an invalid user id throws when verifying the id`() {
assertThrowsInDebug { assertThrowsInDebug {
val userId = UserId("some invalid user id") 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`() { fun `building a permalink for an invalid room id throws when verifying the id`() {
assertThrowsInDebug { assertThrowsInDebug {
val roomId = RoomId("some invalid room id") 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`() { fun `building a permalink for an invalid user id returns failure when not verifying the id`() {
withReleaseBehavior { withReleaseBehavior {
val userId = UserId("some invalid user id") 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`() { fun `building a permalink for an invalid room id returns failure when not verifying the id`() {
withReleaseBehavior { withReleaseBehavior {
val roomId = RoomId("some invalid room id") val roomId = RoomId("some invalid room id")
assertThat(PermalinkBuilder.permalinkForRoomId(roomId).isFailure).isTrue() assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).isFailure).isTrue()
} }
} }
@Test @Test
fun `building a permalink for an invalid room alias returns failure`() { fun `building a permalink for an invalid room alias returns failure`() {
val roomAlias = "an invalid room alias" val roomAlias = "an invalid room alias"
assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).isFailure).isTrue() assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).isFailure).isTrue()
} }
@Test @Test
fun `building a permalink for a valid user id returns a matrix-to url`() { fun `building a permalink for a valid user id returns a matrix-to url`() {
val userId = UserId("@user:matrix.org") 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 @Test
fun `building a permalink for a valid room id returns a matrix-to url`() { fun `building a permalink for a valid room id returns a matrix-to url`() {
val roomId = RoomId("!aBCdEFG1234:matrix.org") 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 @Test
fun `building a permalink for a valid room alias returns a matrix-to url`() { fun `building a permalink for a valid room alias returns a matrix-to url`() {
val roomAlias = "#room:matrix.org" 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")
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,44 +14,60 @@
* limitations under the License. * 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class PermalinkParserTest { class DefaultPermalinkParserTest {
@Test @Test
fun `parsing an invalid url returns a fallback link`() { fun `parsing an invalid url returns a fallback link`() {
val sut = DefaultPermalinkParser(
matrixToConverter = DefaultMatrixToConverter(),
)
val url = "https://element.io" val url = "https://element.io"
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
} }
@Test @Test
fun `parsing an invalid url with the right path but no content returns a fallback link`() { 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" 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 @Test
fun `parsing an invalid url with the right path but empty content returns a fallback link`() { 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/" 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 @Test
fun `parsing an invalid url with the right path but invalid content returns a fallback link`() { 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!" 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 @Test
fun `parsing a valid user url returns a user link`() { 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" val url = "https://app.element.io/#/user/@test:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo( assertThat(sut.parse(url)).isEqualTo(
PermalinkData.UserLink( PermalinkData.UserLink(
userId = "@test:matrix.org" userId = "@test:matrix.org"
) )
@ -60,8 +76,11 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a valid room id url returns a room link`() { 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" val url = "https://app.element.io/#/room/!aBCD1234:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo( assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink( PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org", roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false, isRoomAlias = false,
@ -73,8 +92,11 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a valid room id with event id url returns a room link`() { 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" 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( PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org", roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false, isRoomAlias = false,
@ -86,8 +108,11 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a valid room id with and invalid event id url returns a room link with no event id`() { 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" 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( PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org", roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false, isRoomAlias = false,
@ -99,8 +124,11 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a valid room id with event id and via parameters url returns a room link`() { 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" 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( PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org", roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false, isRoomAlias = false,
@ -112,8 +140,11 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a valid room alias url returns a room link`() { 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" val url = "https://app.element.io/#/room/#element-android:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo( assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink( PermalinkData.RoomLink(
roomIdOrAlias = "#element-android:matrix.org", roomIdOrAlias = "#element-android:matrix.org",
isRoomAlias = true, isRoomAlias = true,
@ -125,6 +156,9 @@ class PermalinkParserTest {
@Test @Test
fun `parsing a url with an invalid signurl returns a fallback link`() { fun `parsing a url with an invalid signurl returns a fallback link`() {
val sut = DefaultPermalinkParser(
matrixToConverter = DefaultMatrixToConverter(),
)
// This url has no private key // This url has no private key
val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" + val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" + "?email=testuser%40element.io" +
@ -135,11 +169,14 @@ class PermalinkParserTest {
"&guest_access_token=" + "&guest_access_token=" +
"&guest_user_id=" + "&guest_user_id=" +
"&room_type=" "&room_type="
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java) assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
} }
@Test @Test
fun `parsing a url with signurl returns a room email invite link`() { 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" + val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" + "?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" + "&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_access_token=" +
"&guest_user_id=" + "&guest_user_id=" +
"&room_type=" "&room_type="
assertThat(PermalinkParser.parse(url)).isEqualTo( assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomEmailInviteLink( PermalinkData.RoomEmailInviteLink(
roomId = "!aBCDEF12345:matrix.org", roomId = "!aBCDEF12345:matrix.org",
email = "testuser@element.io", email = "testuser@element.io",

View file

@ -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<String> = { Result.failure(Exception("Not implemented")) }
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
return result()
}
override fun permalinkForRoomAlias(roomAlias: String): Result<String> {
return result()
}
override fun permalinkForRoomId(roomId: RoomId): Result<String> {
return result()
}
}

View file

@ -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 = { TODO("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")
}
}

View file

@ -47,4 +47,5 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
} }

View file

@ -29,9 +29,13 @@ import org.jsoup.nodes.Document
* *
* This will also make sure mentions are prefixed with `@`. * 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. * @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 return takeIf { it.format == MessageFormat.HTML }?.body
// Trim whitespace at the end to avoid having wrong rendering of the message. // 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. // 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 // Prepend `@` to mentions
fixMentions(dom) fixMentions(dom, permalinkParser)
dom dom
} }
} }
private fun fixMentions(dom: Document) { private fun fixMentions(
dom: Document,
permalinkParser: PermalinkParser,
) {
val links = dom.getElementsByTag("a") val links = dom.getElementsByTag("a")
links.forEach { links.forEach {
if (it.hasAttr("href")) { 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("@")) { if (link is PermalinkData.UserLink && !it.text().startsWith("@")) {
it.prependText("@") it.prependText("@")
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.ui.messages 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.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat 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.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. * 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. * 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. * 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`. * 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. * @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/ */
fun FormattedBody.toPlainText(prefix: String? = null): String? { fun FormattedBody.toPlainText(
return this.toHtmlDocument(prefix)?.toPlainText() permalinkParser: PermalinkParser,
prefix: String? = null,
): String? {
return this.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = prefix,
)?.toPlainText()
} }
/** /**

View file

@ -16,9 +16,13 @@
package io.element.android.libraries.matrixui.messages package io.element.android.libraries.matrixui.messages
import android.net.Uri
import com.google.common.truth.Truth.assertThat 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.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat 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 io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -33,7 +37,7 @@ class ToHtmlDocumentTest {
body = "Hello world" body = "Hello world"
) )
val document = body.toHtmlDocument() val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNull() assertThat(document).isNull()
} }
@ -45,7 +49,7 @@ class ToHtmlDocumentTest {
body = "<p>Hello world</p>" body = "<p>Hello world</p>"
) )
val document = body.toHtmlDocument() val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNotNull() assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("Hello world") assertThat(document?.text()).isEqualTo("Hello world")
} }
@ -57,7 +61,10 @@ class ToHtmlDocumentTest {
body = "<p>Hello world</p>" body = "<p>Hello world</p>"
) )
val document = body.toHtmlDocument(prefix = "@Jorge:") val document = body.toHtmlDocument(
permalinkParser = FakePermalinkParser(),
prefix = "@Jorge:"
)
assertThat(document).isNotNull() assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("@Jorge: Hello world") assertThat(document?.text()).isEqualTo("@Jorge: Hello world")
} }
@ -69,7 +76,13 @@ class ToHtmlDocumentTest {
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>Alice</a>!" body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>Alice</a>!"
) )
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!") assertThat(document?.text()).isEqualTo("Hey @Alice!")
} }
@ -80,7 +93,13 @@ class ToHtmlDocumentTest {
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>@Alice</a>!" body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>@Alice</a>!"
) )
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!") assertThat(document?.text()).isEqualTo("Hey @Alice!")
} }
@ -91,7 +110,13 @@ class ToHtmlDocumentTest {
body = "Hey <a href='https://matrix.org'>Alice</a>!" body = "Hey <a href='https://matrix.org'>Alice</a>!"
) )
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!") assertThat(document?.text()).isEqualTo("Hey Alice!")
} }
} }

View file

@ -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.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat 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.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.junit.Test import org.junit.Test
@ -59,7 +60,7 @@ class ToPlainTextTest {
<br /> <br />
""".trimIndent() """.trimIndent()
) )
assertThat(formattedBody.toPlainText()).isEqualTo( assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
""" """
Hello world Hello world
This is an unordered list. This is an unordered list.
@ -79,7 +80,7 @@ class ToPlainTextTest {
<br /> <br />
""".trimIndent() """.trimIndent()
) )
assertThat(formattedBody.toPlainText()).isNull() assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull()
} }
@Test @Test
@ -96,7 +97,7 @@ class ToPlainTextTest {
""".trimIndent() """.trimIndent()
) )
) )
assertThat(messageType.toPlainText()).isEqualTo( assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
""" """
Hello world Hello world
This is an unordered list. This is an unordered list.
@ -119,6 +120,6 @@ class ToPlainTextTest {
""".trimIndent() """.trimIndent()
) )
) )
assertThat(messageType.toPlainText()).isEqualTo("This is the fallback text") assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text")
} }
} }

View file

@ -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.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent 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.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.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@ -68,6 +69,7 @@ class NotifiableEventResolver @Inject constructor(
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser,
) { ) {
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
// Restore session // Restore session
@ -252,7 +254,7 @@ class NotifiableEventResolver @Inject constructor(
is ImageMessageType -> messageType.body is ImageMessageType -> messageType.body
is StickerMessageType -> messageType.body is StickerMessageType -> messageType.body
is NoticeMessageType -> messageType.body is NoticeMessageType -> messageType.body
is TextMessageType -> messageType.toPlainText() is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
is VideoMessageType -> messageType.body is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body is LocationMessageType -> messageType.body
is OtherMessageType -> messageType.body is OtherMessageType -> messageType.body

View file

@ -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.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.FakeNotificationService 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.fake.FakeNotificationMediaRepo
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -549,6 +550,7 @@ class NotifiableEventResolverTest {
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
notificationMediaRepoFactory = notificationMediaRepoFactory, notificationMediaRepoFactory = notificationMediaRepoFactory,
context = context, context = context,
permalinkParser = FakePermalinkParser(),
) )
} }

View file

@ -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.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource 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.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -98,6 +100,7 @@ import kotlin.time.Duration.Companion.seconds
fun TextComposer( fun TextComposer(
state: RichTextEditorState, state: RichTextEditorState,
voiceMessageState: VoiceMessageState, voiceMessageState: VoiceMessageState,
permalinkParser: PermalinkParser,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
enableTextFormatting: Boolean, enableTextFormatting: Boolean,
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
@ -152,7 +155,10 @@ fun TextComposer(
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable { @Composable {
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId) val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
TextInput( TextInput(
state = state, state = state,
subcomposing = subcomposing, subcomposing = subcomposing,
@ -907,6 +913,10 @@ private fun ATextComposer(
state = richTextEditorState, state = richTextEditorState,
showTextFormatting = showTextFormatting, showTextFormatting = showTextFormatting,
voiceMessageState = voiceMessageState, 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, composerMode = composerMode,
enableTextFormatting = enableTextFormatting, enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer.mentions
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues 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.core.SessionId
import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf
@Stable @Stable
class MentionSpanProvider( class MentionSpanProvider(
private val currentSessionId: SessionId, private val currentSessionId: SessionId,
private val permalinkParser: PermalinkParser,
private var currentUserTextColor: Int = 0, private var currentUserTextColor: Int = 0,
private var currentUserBackgroundColor: Int = Color.WHITE, private var currentUserBackgroundColor: Int = Color.WHITE,
private var otherTextColor: Int = 0, private var otherTextColor: Int = 0,
@ -73,7 +76,7 @@ class MentionSpanProvider(
} }
fun getMentionSpanFor(text: String, url: String): MentionSpan { fun getMentionSpanFor(text: String, url: String): MentionSpan {
val permalinkData = PermalinkParser.parse(url) val permalinkData = permalinkParser.parse(url)
val (startPaddingPx, endPaddingPx) = paddingValuesPx.value val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
return when { return when {
permalinkData is PermalinkData.UserLink -> { permalinkData is PermalinkData.UserLink -> {
@ -112,9 +115,15 @@ class MentionSpanProvider(
} }
@Composable @Composable
fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider { fun rememberMentionSpanProvider(
currentUserId: SessionId,
permalinkParser: PermalinkParser,
): MentionSpanProvider {
val provider = remember(currentUserId) { val provider = remember(currentUserId) {
MentionSpanProvider(currentUserId) MentionSpanProvider(
currentSessionId = currentUserId,
permalinkParser = permalinkParser,
)
} }
provider.setup() provider.setup()
return provider return provider
@ -123,7 +132,26 @@ fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun MentionSpanPreview() { 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 { ElementPreview {
provider.setup() provider.setup()

View file

@ -18,9 +18,12 @@ package io.element.android.libraries.textcomposer.impl.mentions
import android.graphics.Color import android.graphics.Color
import com.google.common.truth.Truth.assertThat 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.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -35,8 +38,10 @@ class MentionSpanProviderTest {
private val otherColor = Color.BLUE private val otherColor = Color.BLUE
private val currentUserId = A_SESSION_ID private val currentUserId = A_SESSION_ID
private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider( private val mentionSpanProvider = MentionSpanProvider(
currentSessionId = currentUserId, currentSessionId = currentUserId,
permalinkParser = permalinkParser,
currentUserBackgroundColor = myUserColor, currentUserBackgroundColor = myUserColor,
currentUserTextColor = myUserColor, currentUserTextColor = myUserColor,
otherBackgroundColor = otherColor, otherBackgroundColor = otherColor,
@ -45,6 +50,7 @@ class MentionSpanProviderTest {
@Test @Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() { fun `getting mention span for current user should return a MentionSpan with custom colors`() {
permalinkParser.givenResult(PermalinkData.UserLink(currentUserId.value))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}")
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor) assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
assertThat(mentionSpan.textColor).isEqualTo(myUserColor) assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
@ -52,6 +58,7 @@ class MentionSpanProviderTest {
@Test @Test
fun `getting mention span for other user should return a MentionSpan with normal colors`() { fun `getting mention span for other user should return a MentionSpan with normal colors`() {
permalinkParser.givenResult(PermalinkData.UserLink("@other:matrix.org"))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor)
@ -59,6 +66,14 @@ class MentionSpanProviderTest {
@Test @Test
fun `getting mention span for a room should return a MentionSpan with normal colors`() { 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") val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor)
@ -66,6 +81,14 @@ class MentionSpanProviderTest {
@Test @Test
fun `getting mention span for @room should return a MentionSpan with normal colors`() { 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", "#") val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor) assertThat(mentionSpan.textColor).isEqualTo(otherColor)

View file

@ -0,0 +1,31 @@
/*
* 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.samples.minimal
import android.net.Uri
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
class OnlyFallbackPermalinkParser : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(Uri.parse(uriString))
}
override fun parse(uri: Uri): PermalinkData {
return PermalinkData.FallbackLink(uri)
}
}

View file

@ -89,6 +89,7 @@ class RoomListScreen(
), ),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider), stateContentFormatter = StateContentFormatter(stringProvider),
permalinkParser = OnlyFallbackPermalinkParser(),
), ),
) )
private val presenter = RoomListPresenter( private val presenter = RoomListPresenter(