Repository: element-hq/element-x-android (language: kotlin)

Issue:
The timeline is rendering event `org.matrix.msc3401.call.member`
### Steps to reproduce

1. Start a call in a room
2. End the call
3. Observe the timeline

<img width="301" alt="image" src="https://github.com/element-hq/element-x-android/assets/3940906/b10ff2f1-8494-42d5-b619-bda71baefbeb">

The event  of type `org.matrix.msc3401.call.member` should be filtered out. EXI seems to filter them out.


### Outcome

#### What did you expect?

#### What happened instead?


### Your phone model

_No response_

### Operating system version

_No response_

### Application version and app store

_No response_

### Homeserver

_No response_

### Will you send logs?

Yes

### Are you willing to provide a PR?

Yes


Discussion hints:
nan

Pull request: Hide call member events
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Type of change

- [ ] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content

<!-- Describe shortly what has been changed -->
- First commit is preparatory work around permalink. It will be need to properly handle permalink computation, when it will be done by the SDK. Need to do this rework now to avoid a circular dependency (see 2nd commit)
- Second commit: move filtered Event configuration to the appconfig module
- Third commit: fixes #2625 

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Fixes #2625 and prepare work around permalink

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->
No impact.

## Tests

<!-- Explain how you tested your development -->

- In a room with a call, observe the the CallMember Events are not rendered anymore.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR


Test command: ./gradlew :libraries:eventformatter:impl:testDebugUnitTest --tests "io.element.android.libraries.eventformatter.impl.DefaultRoomLastMessageFormatterTest" :libraries:textcomposer:impl:testDebugUnitTest --tests "io.element.android.libraries.textcomposer.impl.mentions.MentionSpanProviderTest" :libraries:matrix:impl:testDebugUnitTest --tests "io.element.android.libraries.matrix.impl.permalink.DefaultMatrixToConverterTest" --tests "io.element.android.libraries.matrix.impl.permalink.DefaultPermalinkBuilderTest" --tests "io.element.android.libraries.matrix.impl.permalink.DefaultPermalinkParserTest" :features:messages:impl:testDebugUnitTest --tests "io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentMessageFactoryTest" --tests "io.element.android.features.messages.impl.timeline.model.InReplyToDetailTest" --tests "io.element.android.features.messages.impl.MessagesViewTest" --tests "io.element.android.features.messages.impl.timeline.DefaultHtmlConverterProviderTest" --tests "io.element.android.features.messages.impl.MessagesPresenterTest" --tests "io.element.android.features.messages.impl.timeline.TimelineViewTest" --tests "io.element.android.features.messages.impl.textcomposer.MessageComposerPresenterTest" :libraries:matrixui:testDebugUnitTest --tests "io.element.android.libraries.matrixui.messages.ToPlainTextTest" --tests "io.element.android.libraries.matrixui.messages.ToHtmlDocumentTest" :libraries:push:impl:testDebugUnitTest --tests "io.element.android.libraries.push.impl.notifications.NotifiableEventResolverTest"

Fix patch:
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index 853a775b451..e59799d6854 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -14,16 +14,13 @@
  * limitations under the License.
  */
 plugins {
-    id("java-library")
-    id("com.android.lint")
-    alias(libs.plugins.kotlin.jvm)
+    id("io.element.android-library")
     alias(libs.plugins.anvil)
     alias(libs.plugins.ksp)
 }
 
-java {
-    sourceCompatibility = JavaVersion.VERSION_17
-    targetCompatibility = JavaVersion.VERSION_17
+android {
+    namespace = "io.element.android.appconfig"
 }
 
 anvil {
@@ -33,4 +30,5 @@ anvil {
 dependencies {
     implementation(libs.dagger)
     implementation(projects.libraries.di)
+    implementation(projects.libraries.matrix.api)
 }
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
index c309e012779..2ad29165509 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
@@ -16,6 +16,29 @@
 
 package io.element.android.appconfig
 
+import io.element.android.libraries.matrix.api.room.StateEventType
+
 object TimelineConfig {
     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,
+    )
 }
diff --git a/changelog.d/2625.bugfix b/changelog.d/2625.bugfix
new file mode 100644
index 00000000000..48fea40258f
--- /dev/null
+++ b/changelog.d/2625.bugfix
@@ -0,0 +1,1 @@
+Hide Event org.matrix.msc3401.call.member on the timeline.
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index c81cbebaae5..aca840236ed 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -16,9 +16,12 @@
 
 package io.element.android.features.messages.impl
 
+import android.content.Context
+import android.net.Uri
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
 import com.bumble.appyx.core.lifecycle.subscribe
 import com.bumble.appyx.core.modality.BuildContext
 import com.bumble.appyx.core.node.Node
@@ -31,11 +34,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
 import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
 import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.androidutils.system.openUrlInExternalApp
 import io.element.android.libraries.core.bool.orFalse
 import io.element.android.libraries.di.RoomScope
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
 import io.element.android.libraries.mediaplayer.api.MediaPlayer
@@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
     presenterFactory: MessagesPresenter.Factory,
     private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
     private val mediaPlayer: MediaPlayer,
+    private val permalinkParser: PermalinkParser,
 ) : Node(buildContext, plugins = plugins), MessagesNavigator {
     private val presenter = presenterFactory.create(this)
     private val callback = plugins<Callback>().firstOrNull()
@@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor(
     private fun onUserDataClicked(userId: UserId) {
         callback?.onUserDataClicked(userId)
     }
+
+    private fun onLinkClicked(
+        context: Context,
+        url: String,
+    ) {
+        when (val permalink = permalinkParser.parse(Uri.parse(url))) {
+            is PermalinkData.UserLink -> {
+                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) {
         callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
     }
@@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor(
 
     @Composable
     override fun View(modifier: Modifier) {
+        val context = LocalContext.current
         CompositionLocalProvider(
             LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
         ) {
@@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor(
                 onEventClicked = this::onEventClicked,
                 onPreviewAttachments = this::onPreviewAttachments,
                 onUserDataClicked = this::onUserDataClicked,
+                onLinkClicked = { onLinkClicked(context, it) },
                 onSendLocationClicked = this::onSendLocationClicked,
                 onCreatePollClicked = this::onCreatePollClicked,
                 onJoinCallClicked = this::onJoinCallClicked,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 91cccd88e83..9cf4375769b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -119,6 +119,7 @@ fun MessagesView(
     onRoomDetailsClicked: () -> Unit,
     onEventClicked: (event: TimelineItem.Event) -> Boolean,
     onUserDataClicked: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
     onSendLocationClicked: () -> Unit,
     onCreatePollClicked: () -> Unit,
@@ -213,6 +214,7 @@ fun MessagesView(
                 onMessageClicked = ::onMessageClicked,
                 onMessageLongClicked = ::onMessageLongClicked,
                 onUserDataClicked = onUserDataClicked,
+                onLinkClicked = onLinkClicked,
                 onTimestampClicked = { event ->
                     if (event.localSendState is LocalEventSendState.SendingFailed) {
                         state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
@@ -313,6 +315,7 @@ private fun MessagesViewContent(
     state: MessagesState,
     onMessageClicked: (TimelineItem.Event) -> Unit,
     onUserDataClicked: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
     onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
     onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
@@ -386,6 +389,7 @@ private fun MessagesViewContent(
                     onMessageClicked = onMessageClicked,
                     onMessageLongClicked = onMessageLongClicked,
                     onUserDataClicked = onUserDataClicked,
+                    onLinkClicked = onLinkClicked,
                     onTimestampClicked = onTimestampClicked,
                     onReactionClicked = onReactionClicked,
                     onReactionLongClicked = onReactionLongClicked,
@@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
         onEventClicked = { false },
         onPreviewAttachments = {},
         onUserDataClicked = {},
+        onLinkClicked = {},
         onSendLocationClicked = {},
         onCreatePollClicked = {},
         onJoinCallClicked = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 37e404d3142..04c182a5664 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -49,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
 import io.element.android.libraries.matrix.api.core.ProgressCallback
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.Mention
 import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor(
     private val messageComposerContext: MessageComposerContextImpl,
     private val richTextEditorStateFactory: RichTextEditorStateFactory,
     private val currentSessionIdHolder: CurrentSessionIdHolder,
+    private val permalinkParser: PermalinkParser,
+    private val permalinkBuilder: PermalinkBuilder,
     permissionsPresenterFactory: PermissionsPresenter.Factory,
 ) : Presenter<MessageComposerState> {
     private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor(
                             }
                             is MentionSuggestion.Member -> {
                                 val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
-                                val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
+                                val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
                                 richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
                             }
                         }
@@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor(
 
         return MessageComposerState(
             richTextEditorState = richTextEditorState,
+            permalinkParser = permalinkParser,
             isFullScreen = isFullScreen.value,
             mode = messageComposerContext.composerMode,
             showAttachmentSourcePicker = showAttachmentSourcePicker,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index 09eb51477f5..194ce1914cd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Stable
 import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.mentions.MentionSuggestion
 import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.wysiwyg.compose.RichTextEditorState
 import kotlinx.collections.immutable.ImmutableList
@@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
 @Stable
 data class MessageComposerState(
     val richTextEditorState: RichTextEditorState,
+    val permalinkParser: PermalinkParser,
     val isFullScreen: Boolean,
     val mode: MessageComposerMode,
     val showAttachmentSourcePicker: Boolean,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index da3c0c8af73..fb7f616e123 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -16,9 +16,12 @@
 
 package io.element.android.features.messages.impl.messagecomposer
 
+import android.net.Uri
 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
 import io.element.android.features.messages.impl.mentions.MentionSuggestion
 import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.wysiwyg.compose.RichTextEditorState
 import kotlinx.collections.immutable.ImmutableList
@@ -43,6 +46,10 @@ fun aMessageComposerState(
     memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
 ) = MessageComposerState(
     richTextEditorState = richTextEditorState,
+    permalinkParser = object : PermalinkParser {
+        override fun parse(uriString: String): PermalinkData = TODO()
+        override fun parse(uri: Uri): PermalinkData = TODO()
+    },
     isFullScreen = isFullScreen,
     mode = mode,
     showTextFormatting = showTextFormatting,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index 6cb2900a20d..e68cf298447 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -109,6 +109,7 @@ internal fun MessageComposerView(
         modifier = modifier,
         state = state.richTextEditorState,
         voiceMessageState = voiceMessageState.voiceMessageState,
+        permalinkParser = state.permalinkParser,
         subcomposing = subcomposing,
         onRequestFocus = ::onRequestFocus,
         onSendMessage = ::sendMessage,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
index aefadcbb25d..26ad72dcf9c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.core.bool.orFalse
 import io.element.android.libraries.di.SessionScope
 import io.element.android.libraries.di.SingleIn
 import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
 import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
 import io.element.android.wysiwyg.compose.StyledHtmlConverter
@@ -39,7 +40,9 @@ import javax.inject.Inject
 
 @ContributesBinding(SessionScope::class)
 @SingleIn(SessionScope::class)
-class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
+class DefaultHtmlConverterProvider @Inject constructor(
+    private val permalinkParser: PermalinkParser,
+) : HtmlConverterProvider {
     private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
 
     @Composable
@@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
         }
 
         val editorStyle = ElementRichTextEditorStyle.textStyle()
-        val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
+        val mentionSpanProvider = rememberMentionSpanProvider(
+            currentUserId = currentUserId,
+            permalinkParser = permalinkParser,
+        )
 
         val context = LocalContext.current
 
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index 094ecb6c3bb..58692387532 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -81,6 +81,7 @@ fun TimelineView(
     typingNotificationState: TypingNotificationState,
     roomName: String?,
     onUserDataClicked: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onMessageClicked: (TimelineItem.Event) -> Unit,
     onMessageLongClicked: (TimelineItem.Event) -> Unit,
     onTimestampClicked: (TimelineItem.Event) -> Unit,
@@ -140,6 +141,7 @@ fun TimelineView(
                         onClick = onMessageClicked,
                         onLongClick = onMessageLongClicked,
                         onUserDataClick = onUserDataClicked,
+                        onLinkClicked = onLinkClicked,
                         inReplyToClick = ::inReplyToClicked,
                         onReactionClick = onReactionClicked,
                         onReactionLongClick = onReactionLongClicked,
@@ -276,6 +278,7 @@ internal fun TimelineViewPreview(
             onMessageClicked = {},
             onTimestampClicked = {},
             onUserDataClicked = {},
+            onLinkClicked = {},
             onMessageLongClicked = {},
             onReactionClicked = { _, _ -> },
             onReactionLongClicked = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
index a86095b3fc3..92b12b2dc80 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
@@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow(
     onClick = {},
     onLongClick = {},
     onUserDataClick = {},
+    onLinkClicked = {},
     inReplyToClick = {},
     onReactionClick = { _, _ -> },
     onReactionLongClick = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index 81c10ad7d8c..22bce0e873e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -17,7 +17,6 @@
 package io.element.android.features.messages.impl.timeline.components
 
 import android.annotation.SuppressLint
-import android.net.Uri
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
@@ -50,7 +49,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalViewConfiguration
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.res.stringResource
@@ -94,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
 import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
 import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
 import io.element.android.features.messages.impl.timeline.model.metadata
-import io.element.android.libraries.androidutils.system.openUrlInExternalApp
 import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
 import io.element.android.libraries.designsystem.components.EqualWidthColumn
 import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -109,9 +106,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.Text
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.matrix.api.permalink.PermalinkParser
-import io.element.android.libraries.matrix.api.room.Mention
 import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
 import io.element.android.libraries.testtags.TestTags
 import io.element.android.libraries.ui.strings.CommonStrings
@@ -128,6 +122,7 @@ fun TimelineItemEventRow(
     isHighlighted: Boolean,
     onClick: () -> Unit,
     onLongClick: () -> Unit,
+    onLinkClicked: (String) -> Unit,
     onUserDataClick: (UserId) -> Unit,
     inReplyToClick: (EventId) -> Unit,
     onTimestampClicked: (TimelineItem.Event) -> Unit,
@@ -151,13 +146,6 @@ fun TimelineItemEventRow(
         inReplyToClick(inReplyToEventId)
     }
 
-    fun onMentionClicked(mention: Mention) {
-        when (mention) {
-            is Mention.User -> onUserDataClick(mention.userId)
-            else -> Unit // TODO implement actions for other mentions being clicked
-        }
-    }
-
     Column(modifier = modifier.fillMaxWidth()) {
         if (event.groupPosition.isNew()) {
             Spacer(modifier = Modifier.height(16.dp))
@@ -203,7 +191,7 @@ fun TimelineItemEventRow(
                         onReactionClicked = { emoji -> onReactionClick(emoji, event) },
                         onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
                         onMoreReactionsClicked = { onMoreReactionsClick(event) },
-                        onMentionClicked = ::onMentionClicked,
+                        onLinkClicked = onLinkClicked,
                         eventSink = eventSink,
                     )
                 }
@@ -222,7 +210,7 @@ fun TimelineItemEventRow(
                 onReactionClicked = { emoji -> onReactionClick(emoji, event) },
                 onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
                 onMoreReactionsClicked = { onMoreReactionsClick(event) },
-                onMentionClicked = ::onMentionClicked,
+                onLinkClicked = onLinkClicked,
                 eventSink = eventSink,
             )
         }
@@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent(
     onReactionClicked: (emoji: String) -> Unit,
     onReactionLongClicked: (emoji: String) -> Unit,
     onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
-    onMentionClicked: (Mention) -> Unit,
+    onLinkClicked: (String) -> Unit,
     eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent(
                 onTimestampClicked = {
                     onTimestampClicked(event)
                 },
-                onMentionClicked = onMentionClicked,
+                onLinkClicked = onLinkClicked,
                 eventSink = eventSink,
             )
         }
@@ -429,7 +417,7 @@ private fun MessageEventBubbleContent(
     @Suppress("UNUSED_PARAMETER")
     inReplyToClick: () -> Unit,
     onTimestampClicked: () -> Unit,
-    onMentionClicked: (Mention) -> Unit,
+    onLinkClicked: (String) -> Unit,
     eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
     @SuppressLint("ModifierParameter")
     // need to rename this modifier to prevent linter false positives
@@ -530,7 +518,6 @@ private fun MessageEventBubbleContent(
         modifier: Modifier = Modifier,
         canShrinkContent: Boolean = false,
     ) {
-        val context = LocalContext.current
         val timestampLayoutModifier: Modifier
         val contentModifier: Modifier
         when {
@@ -566,20 +553,7 @@ private fun MessageEventBubbleContent(
             ) { onContentLayoutChanged ->
                 TimelineItemEventContentView(
                     content = event.content,
-                    onLinkClicked = { url ->
-                        when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
-                            is PermalinkData.UserLink -> {
-                                onMentionClicked(Mention.User(UserId(permalink.userId)))
-                            }
-                            is PermalinkData.RoomLink -> {
-                                onMentionClicked(Mention.Room(permalink.getRoomId(), permalink.getRoomAlias()))
-                            }
-                            is PermalinkData.FallbackLink,
-                            is PermalinkData.RoomEmailInviteLink -> {
-                                context.openUrlInExternalApp(url)
-                            }
-                        }
-                    },
+                    onLinkClicked = onLinkClicked,
                     eventSink = eventSink,
                     onContentLayoutChanged = onContentLayoutChanged,
                     modifier = contentModifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
index 74998afb892..bc05882f1b0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
@@ -51,6 +51,7 @@ fun TimelineItemGroupedEventsRow(
     onLongClick: (TimelineItem.Event) -> Unit,
     inReplyToClick: (EventId) -> Unit,
     onUserDataClick: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onTimestampClicked: (TimelineItem.Event) -> Unit,
     onReactionClick: (key: String, TimelineItem.Event) -> Unit,
     onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -78,6 +79,7 @@ fun TimelineItemGroupedEventsRow(
         onLongClick = onLongClick,
         inReplyToClick = inReplyToClick,
         onUserDataClick = onUserDataClick,
+        onLinkClicked = onLinkClicked,
         onTimestampClicked = onTimestampClicked,
         onReactionClick = onReactionClick,
         onReactionLongClick = onReactionLongClick,
@@ -102,6 +104,7 @@ private fun TimelineItemGroupedEventsRowContent(
     onLongClick: (TimelineItem.Event) -> Unit,
     inReplyToClick: (EventId) -> Unit,
     onUserDataClick: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onTimestampClicked: (TimelineItem.Event) -> Unit,
     onReactionClick: (key: String, TimelineItem.Event) -> Unit,
     onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -135,6 +138,7 @@ private fun TimelineItemGroupedEventsRowContent(
                         onLongClick = onLongClick,
                         inReplyToClick = inReplyToClick,
                         onUserDataClick = onUserDataClick,
+                        onLinkClicked = onLinkClicked,
                         onTimestampClicked = onTimestampClicked,
                         onReactionClick = onReactionClick,
                         onReactionLongClick = onReactionLongClick,
@@ -175,6 +179,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
         onLongClick = {},
         inReplyToClick = {},
         onUserDataClick = {},
+        onLinkClicked = {},
         onTimestampClicked = {},
         onReactionClick = { _, _ -> },
         onReactionLongClick = { _, _ -> },
@@ -200,6 +205,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
         onLongClick = {},
         inReplyToClick = {},
         onUserDataClick = {},
+        onLinkClicked = {},
         onTimestampClicked = {},
         onReactionClick = { _, _ -> },
         onReactionLongClick = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
index 6acddd179d2..5146c87df64 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
@@ -36,6 +36,7 @@ internal fun TimelineItemRow(
     highlightedItem: String?,
     sessionState: SessionState,
     onUserDataClick: (UserId) -> Unit,
+    onLinkClicked: (String) -> Unit,
     onClick: (TimelineItem.Event) -> Unit,
     onLongClick: (TimelineItem.Event) -> Unit,
     inReplyToClick: (EventId) -> Unit,
@@ -79,6 +80,7 @@ internal fun TimelineItemRow(
                     onClick = { onClick(timelineItem) },
                     onLongClick = { onLongClick(timelineItem) },
                     onUserDataClick = onUserDataClick,
+                    onLinkClicked = onLinkClicked,
                     inReplyToClick = inReplyToClick,
                     onReactionClick = onReactionClick,
                     onReactionLongClick = onReactionLongClick,
@@ -103,6 +105,7 @@ internal fun TimelineItemRow(
                 onLongClick = onLongClick,
                 inReplyToClick = inReplyToClick,
                 onUserDataClick = onUserDataClick,
+                onLinkClicked = onLinkClicked,
                 onTimestampClicked = onTimestampClicked,
                 onReactionClick = onReactionClick,
                 onReactionLongClick = onReactionLongClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index af1ef0bc3a4..acd99d37045 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
 import io.element.android.libraries.featureflag.api.FeatureFlagService
 import io.element.android.libraries.featureflag.api.FeatureFlags
 import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@@ -67,6 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
     private val fileExtensionExtractor: FileExtensionExtractor,
     private val featureFlagService: FeatureFlagService,
     private val htmlConverterProvider: HtmlConverterProvider,
+    private val permalinkParser: PermalinkParser,
 ) {
     suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
         return when (val messageType = content.type) {
@@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
                 TimelineItemEmoteContent(
                     body = emoteBody,
-                    htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
+                    htmlDocument = messageType.formatted?.toHtmlDocument(
+                        permalinkParser = permalinkParser,
+                        prefix = "* $senderDisplayName",
+                    ),
                     formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
                     isEdited = content.isEdited,
                 )
@@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 val body = messageType.body.trimEnd()
                 TimelineItemNoticeContent(
                     body = body,
-                    htmlDocument = messageType.formatted?.toHtmlDocument(),
+                    htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
                     formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
                     isEdited = content.isEdited,
                 )
@@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 val body = messageType.body.trimEnd()
                 TimelineItemTextContent(
                     body = body,
-                    htmlDocument = messageType.formatted?.toHtmlDocument(),
+                    htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
                     formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
                     isEdited = content.isEdited,
                 )
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index 38125dcef7a..0522379f722 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
 import io.element.android.libraries.designsystem.components.avatar.AvatarData
 import io.element.android.libraries.designsystem.components.avatar.AvatarSize
 import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.room.RoomMember
 import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
 import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@@ -43,6 +44,7 @@ class TimelineItemEventFactory @Inject constructor(
     private val contentFactory: TimelineItemContentFactory,
     private val matrixClient: MatrixClient,
     private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
+    private val permalinkParser: PermalinkParser,
 ) {
     suspend fun create(
         currentTimelineItem: MatrixTimelineItem.Event,
@@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
             reactionsState = currentTimelineItem.computeReactionsState(),
             readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
             localSendState = currentTimelineItem.event.localSendState,
-            inReplyTo = currentTimelineItem.event.inReplyTo()?.map(),
+            inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
             isThreaded = currentTimelineItem.event.isThreaded(),
             debugInfo = currentTimelineItem.event.debugInfo,
             origin = currentTimelineItem.event.origin,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
index 759ead5b61f..3c629f23fc3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
 
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
 import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
 import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -34,7 +35,9 @@ data class InReplyToDetails(
     val textContent: String?,
 )
 
-fun InReplyTo.map() = when (this) {
+fun InReplyTo.map(
+    permalinkParser: PermalinkParser,
+) = when (this) {
     is InReplyTo.Ready -> InReplyToDetails(
         eventId = eventId,
         senderId = senderId,
@@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) {
         textContent = when (content) {
             is MessageContent -> {
                 val messageContent = content as MessageContent
-                (messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body
+                (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
             }
             is StickerContent -> {
                 val stickerContent = content as StickerContent
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
index 2fea39eeb73..3b09afb7fc9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
@@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview(
         onEventClicked = { false },
         onPreviewAttachments = {},
         onUserDataClicked = {},
+        onLinkClicked = {},
         onSendLocationClicked = {},
         onCreatePollClicked = {},
         onJoinCallClicked = {},
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
index 63cc745eccc..6bf8b00dd7d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
@@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
     private val presenter: RoomDetailsPresenter,
     private val room: MatrixRoom,
     private val analyticsService: AnalyticsService,
+    private val permalinkBuilder: PermalinkBuilder,
 ) : Node(buildContext, plugins = plugins) {
     interface Callback : Plugin {
         fun openRoomMemberList()
@@ -84,8 +85,8 @@ class RoomDetailsNode @AssistedInject constructor(
 
     private fun onShareRoom(context: Context) {
         val alias = room.alias ?: room.alternativeAliases.firstOrNull()
-        val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
-            ?: PermalinkBuilder.permalinkForRoomId(room.roomId)
+        val permalinkResult = alias?.let { permalinkBuilder.permalinkForRoomAlias(it) }
+            ?: permalinkBuilder.permalinkForRoomId(room.roomId)
         permalinkResult.onSuccess { permalink ->
             context.startSharePlainTextIntent(
                 activityResultLauncher = null,
@@ -99,7 +100,7 @@ class RoomDetailsNode @AssistedInject constructor(
     }
 
     private fun onShareMember(context: Context, member: RoomMember) {
-        val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
+        val permalinkResult = permalinkBuilder.permalinkForUser(member.userId)
         permalinkResult.onSuccess { permalink ->
             context.startSharePlainTextIntent(
                 activityResultLauncher = null,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
index bdf7385661b..71cd975e187 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
@@ -46,6 +46,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
     @Assisted buildContext: BuildContext,
     @Assisted plugins: List<Plugin>,
     private val analyticsService: AnalyticsService,
+    private val permalinkBuilder: PermalinkBuilder,
     presenterFactory: RoomMemberDetailsPresenter.Factory,
 ) : Node(buildContext, plugins = plugins) {
     interface Callback : NodeInputs {
@@ -74,7 +75,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
         val context = LocalContext.current
 
         fun onShareUser() {
-            val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
+            val permalinkResult = permalinkBuilder.permalinkForUser(inputs.roomMemberId)
             permalinkResult.onSuccess { permalink ->
                 context.startSharePlainTextIntent(
                     activityResultLauncher = null,
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
index 24296f87986..b0d3f3a3399 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
@@ -31,9 +31,10 @@ class InviteFriendsUseCase @Inject constructor(
     private val stringProvider: StringProvider,
     private val matrixClient: MatrixClient,
     private val buildMeta: BuildMeta,
+    private val permalinkBuilder: PermalinkBuilder,
 ) {
     fun execute(activity: Activity) {
-        val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId)
+        val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId)
         permalinkResult.fold(
             onSuccess = { permalink ->
                 val appName = buildMeta.applicationName
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index e60840768e7..ffaabb45eaa 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -25,6 +25,7 @@ import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.libraries.di.SessionScope
 import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
 import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@@ -62,6 +63,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
     private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
     private val profileChangeContentFormatter: ProfileChangeContentFormatter,
     private val stateContentFormatter: StateContentFormatter,
+    private val permalinkParser: PermalinkParser
 ) : RoomLastMessageFormatter {
     companion object {
         // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105
@@ -121,7 +123,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
                 return "* $senderDisplayName ${messageType.body}"
             }
             is TextMessageType -> {
-                messageType.toPlainText()
+                messageType.toPlainText(permalinkParser)
             }
             is VideoMessageType -> {
                 sp.getString(CommonStrings.common_video)
diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts
index 9304634be2b..62192967324 100644
--- a/libraries/matrix/api/build.gradle.kts
+++ b/libraries/matrix/api/build.gradle.kts
@@ -34,7 +34,6 @@ anvil {
 }
 
 dependencies {
-    implementation(projects.appconfig)
     implementation(projects.libraries.di)
     implementation(libs.dagger)
     implementation(projects.libraries.androidutils)
@@ -45,7 +44,5 @@ dependencies {
 
     testImplementation(libs.test.junit)
     testImplementation(libs.test.truth)
-    testImplementation(libs.test.robolectric)
-    testImplementation(projects.tests.testutils)
     testImplementation(projects.libraries.matrix.test)
 }
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
index 4ae7b7dc948..c525bd4c008 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
@@ -17,40 +17,17 @@
 package io.element.android.libraries.matrix.api.permalink
 
 import android.net.Uri
-import io.element.android.appconfig.MatrixConfiguration
 
 /**
  * Mapping of an input URI to a matrix.to compliant URI.
  */
-object MatrixToConverter {
+interface MatrixToConverter {
     /**
      * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
-     * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
      * Examples:
      * - https://riot.im/develop/#/room/#element-android:matrix.org  ->  https://matrix.to/#/#element-android:matrix.org
      * - https://app.element.io/#/room/#element-android:matrix.org   ->  https://matrix.to/#/#element-android:matrix.org
      * - https://www.example.org/#/room/#element-android:matrix.org  ->  https://matrix.to/#/#element-android:matrix.org
      */
-    fun convert(uri: Uri): Uri? {
-        val uriString = uri.toString()
-        val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
-
-        return when {
-            // URL is already a matrix.to
-            uriString.startsWith(baseUrl) -> uri
-            // Web or client url
-            SUPPORTED_PATHS.any { it in uriString } -> {
-                val path = SUPPORTED_PATHS.first { it in uriString }
-                Uri.parse(baseUrl + uriString.substringAfter(path))
-            }
-            // URL is not supported
-            else -> null
-        }
-    }
-
-    private val SUPPORTED_PATHS = listOf(
-        "/#/room/",
-        "/#/user/",
-        "/#/group/"
-    )
+    fun convert(uri: Uri): Uri?
 }
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
index c46e15db3b4..14c29f2de51 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
@@ -16,70 +16,13 @@
 
 package io.element.android.libraries.matrix.api.permalink
 
-import io.element.android.appconfig.MatrixConfiguration
-import io.element.android.libraries.matrix.api.core.MatrixPatterns
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.core.UserId
 
-object PermalinkBuilder {
-    private const val ROOM_PATH = "room/"
-    private const val USER_PATH = "user/"
-
-    private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also {
-        var baseUrl = it
-        if (!baseUrl.endsWith("/")) {
-            baseUrl += "/"
-        }
-        if (!baseUrl.endsWith("/#/")) {
-            baseUrl += "/#/"
-        }
-    }
-
-    fun permalinkForUser(userId: UserId): Result<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)
+interface PermalinkBuilder {
+    fun permalinkForUser(userId: UserId): Result<String>
+    fun permalinkForRoomAlias(roomAlias: String): Result<String>
+    fun permalinkForRoomId(roomId: RoomId): Result<String>
 }
 
 sealed class PermalinkBuilderError : Throwable() {
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
index 3b90aee1be6..463f8fb32dc 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
@@ -17,11 +17,6 @@
 package io.element.android.libraries.matrix.api.permalink
 
 import android.net.Uri
-import android.net.UrlQuerySanitizer
-import io.element.android.libraries.matrix.api.core.MatrixPatterns
-import kotlinx.collections.immutable.toImmutableList
-import timber.log.Timber
-import java.net.URLDecoder
 
 /**
  * This class turns a uri to a [PermalinkData].
@@ -29,121 +24,15 @@ import java.net.URLDecoder
  * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
  * or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
  */
-object PermalinkParser {
+interface PermalinkParser {
     /**
      * Turns a uri string to a [PermalinkData].
      */
-    fun parse(uriString: String): PermalinkData {
-        val uri = Uri.parse(uriString)
-        return parse(uri)
-    }
+    fun parse(uriString: String): PermalinkData
 
     /**
      * Turns a uri to a [PermalinkData].
      * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
      */
-    fun parse(uri: Uri): PermalinkData {
-        // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
-        // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
-        // so convert URI to matrix.to to simplify parsing process
-        val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
-
-        // We can't use uri.fragment as it is decoding to early and it will break the parsing
-        // of parameters that represents url (like signurl)
-        val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment
-        if (fragment.isEmpty()) {
-            return PermalinkData.FallbackLink(uri)
-        }
-        val safeFragment = fragment.substringBefore('?')
-        val viaQueryParameters = fragment.getViaParameters()
-
-        // we are limiting to 2 params
-        val params = safeFragment
-            .split(MatrixPatterns.SEP_REGEX)
-            .filter { it.isNotEmpty() }
-            .take(2)
-
-        val decodedParams = params
-            .map { URLDecoder.decode(it, "UTF-8") }
-
-        val identifier = params.getOrNull(0)
-        val decodedIdentifier = decodedParams.getOrNull(0)
-        val extraParameter = decodedParams.getOrNull(1)
-        return when {
-            identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri)
-            MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier)
-            MatrixPatterns.isRoomId(decodedIdentifier) -> {
-                handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters)
-            }
-            MatrixPatterns.isRoomAlias(decodedIdentifier) -> {
-                PermalinkData.RoomLink(
-                    roomIdOrAlias = decodedIdentifier,
-                    isRoomAlias = true,
-                    eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
-                    viaParameters = viaQueryParameters.toImmutableList()
-                )
-            }
-            else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
-        }
-    }
-
-    private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List<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())
-    }
+    fun parse(uri: Uri): PermalinkData
 }
diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts
index d50bad6696f..523ea2fda18 100644
--- a/libraries/matrix/impl/build.gradle.kts
+++ b/libraries/matrix/impl/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
     } else {
         debugImplementation(libs.matrix.sdk)
     }
+    implementation(projects.appconfig)
     implementation(projects.libraries.di)
     implementation(projects.libraries.androidutils)
     implementation(projects.libraries.network)
@@ -52,8 +53,10 @@ dependencies {
 
     testImplementation(libs.test.junit)
     testImplementation(libs.test.truth)
+    testImplementation(libs.test.robolectric)
     testImplementation(projects.libraries.matrix.test)
     testImplementation(projects.services.analytics.test)
+    testImplementation(projects.tests.testutils)
     testImplementation(libs.coroutines.test)
     testImplementation(libs.test.turbine)
 }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 62691e8cded..0212a5293ad 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -16,6 +16,7 @@
 
 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.safeDelete
 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.RoomSyncSubscriber
 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.roomlist.RoomListFactory
 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.Room
 import org.matrix.rustcomponents.sdk.RoomListItem
-import org.matrix.rustcomponents.sdk.StateEventType
 import org.matrix.rustcomponents.sdk.TaskHandle
 import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
 import org.matrix.rustcomponents.sdk.use
@@ -229,24 +230,15 @@ class RustMatrixClient(
         ),
     )
 
-    private val eventFilters = TimelineEventTypeFilter.exclude(
-        listOf(
-            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,
-        ).map(FilterTimelineEventType::State)
-    )
+    private val eventFilters = TimelineConfig.excludedEvents
+        .takeIf { it.isNotEmpty() }
+        ?.let { listStateEventType ->
+            TimelineEventTypeFilter.exclude(
+                listStateEventType.map { stateEventType ->
+                    FilterTimelineEventType.State(stateEventType.map())
+                }
+            )
+        }
 
     override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
         baseCacheDirectory = baseCacheDirectory,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt
new file mode 100644
index 00000000000..a5271c0b227
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt
@@ -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/"
+        )
+    }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
new file mode 100644
index 00000000000..ae30c94c9ca
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.permalink
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.MatrixConfiguration
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
+    private val permalinkBaseUrl
+        get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also {
+            var baseUrl = it
+            if (!baseUrl.endsWith("/")) {
+                baseUrl += "/"
+            }
+            if (!baseUrl.endsWith("/#/")) {
+                baseUrl += "/#/"
+            }
+        }
+
+    override fun permalinkForUser(userId: UserId): Result<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/"
+    }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt
new file mode 100644
index 00000000000..ab91a89af91
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.permalink
+
+import android.net.Uri
+import android.net.UrlQuerySanitizer
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import kotlinx.collections.immutable.toImmutableList
+import timber.log.Timber
+import java.net.URLDecoder
+import javax.inject.Inject
+
+/**
+ * This class turns a uri to a [PermalinkData].
+ * element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks
+ * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
+ * or client permalinks (e.g. <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())
+    }
+}
diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts
index 669f9c9634a..fa7a946959e 100644
--- a/libraries/matrixui/build.gradle.kts
+++ b/libraries/matrixui/build.gradle.kts
@@ -47,4 +47,5 @@ dependencies {
     testImplementation(libs.test.junit)
     testImplementation(libs.test.truth)
     testImplementation(libs.test.robolectric)
+    testImplementation(projects.libraries.matrix.test)
 }
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
index b3c6a0bf861..36ae94d7f70 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
@@ -29,9 +29,13 @@ import org.jsoup.nodes.Document
  *
  * This will also make sure mentions are prefixed with `@`.
  *
+ * @param permalinkParser the parser to use to parse the mentions.
  * @param prefix if not null, the prefix will be inserted at the beginning of the message.
  */
-fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
+fun FormattedBody.toHtmlDocument(
+    permalinkParser: PermalinkParser,
+    prefix: String? = null,
+): Document? {
     return takeIf { it.format == MessageFormat.HTML }?.body
         // Trim whitespace at the end to avoid having wrong rendering of the message.
         // We don't trim the start in case it's used as indentation.
@@ -44,17 +48,20 @@ fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
             }
 
             // Prepend `@` to mentions
-            fixMentions(dom)
+            fixMentions(dom, permalinkParser)
 
             dom
         }
 }
 
-private fun fixMentions(dom: Document) {
+private fun fixMentions(
+    dom: Document,
+    permalinkParser: PermalinkParser,
+) {
     val links = dom.getElementsByTag("a")
     links.forEach {
         if (it.hasAttr("href")) {
-            val link = PermalinkParser.parse(it.attr("href"))
+            val link = permalinkParser.parse(it.attr("href"))
             if (link is PermalinkData.UserLink && !it.text().startsWith("@")) {
                 it.prependText("@")
             }
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
index 0cea2d7ae2d..1eb581af65d 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
@@ -16,6 +16,7 @@
 
 package io.element.android.libraries.matrix.ui.messages
 
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
 import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -29,15 +30,24 @@ import org.jsoup.select.NodeVisitor
  * Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting.
  * If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead.
  */
-fun TextMessageType.toPlainText() = formatted?.toPlainText() ?: body
+fun TextMessageType.toPlainText(
+    permalinkParser: PermalinkParser,
+) = formatted?.toPlainText(permalinkParser) ?: body
 
 /**
  * Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting.
  * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
+ * @param permalinkParser the parser to use to parse the mentions.
  * @param prefix if not null, the prefix will be inserted at the beginning of the message.
  */
-fun FormattedBody.toPlainText(prefix: String? = null): String? {
-    return this.toHtmlDocument(prefix)?.toPlainText()
+fun FormattedBody.toPlainText(
+    permalinkParser: PermalinkParser,
+    prefix: String? = null,
+): String? {
+    return this.toHtmlDocument(
+        permalinkParser = permalinkParser,
+        prefix = prefix,
+    )?.toPlainText()
 }
 
 /**
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
index 3b2ac193138..8a285e3b329 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.notification.NotificationContent
 import io.element.android.libraries.matrix.api.notification.NotificationData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.room.RoomMembershipState
 import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@@ -68,6 +69,7 @@ class NotifiableEventResolver @Inject constructor(
     private val matrixClientProvider: MatrixClientProvider,
     private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
     @ApplicationContext private val context: Context,
+    private val permalinkParser: PermalinkParser,
 ) {
     suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
         // Restore session
@@ -252,7 +254,7 @@ class NotifiableEventResolver @Inject constructor(
             is ImageMessageType -> messageType.body
             is StickerMessageType -> messageType.body
             is NoticeMessageType -> messageType.body
-            is TextMessageType -> messageType.toPlainText()
+            is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
             is VideoMessageType -> messageType.body
             is LocationMessageType -> messageType.body
             is OtherMessageType -> messageType.body
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
index 91c8bd96663..caa5e9f96b7 100644
--- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
@@ -63,6 +63,8 @@ import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.TransactionId
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
 import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
 import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@@ -98,6 +100,7 @@ import kotlin.time.Duration.Companion.seconds
 fun TextComposer(
     state: RichTextEditorState,
     voiceMessageState: VoiceMessageState,
+    permalinkParser: PermalinkParser,
     composerMode: MessageComposerMode,
     enableTextFormatting: Boolean,
     enableVoiceMessages: Boolean,
@@ -152,7 +155,10 @@ fun TextComposer(
 
     val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
         @Composable {
-            val mentionSpanProvider = rememberMentionSpanProvider(currentUserId)
+            val mentionSpanProvider = rememberMentionSpanProvider(
+                currentUserId = currentUserId,
+                permalinkParser = permalinkParser,
+            )
             TextInput(
                 state = state,
                 subcomposing = subcomposing,
@@ -907,6 +913,10 @@ private fun ATextComposer(
         state = richTextEditorState,
         showTextFormatting = showTextFormatting,
         voiceMessageState = voiceMessageState,
+        permalinkParser = object : PermalinkParser {
+            override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
+            override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented")
+        },
         composerMode = composerMode,
         enableTextFormatting = enableTextFormatting,
         enableVoiceMessages = enableVoiceMessages,
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
index f7b28d65e80..e50b0fc16a4 100644
--- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
@@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer.mentions
 
 import android.graphics.Color
 import android.graphics.Typeface
+import android.net.Uri
 import android.view.ViewGroup
 import android.widget.TextView
 import androidx.compose.foundation.layout.PaddingValues
@@ -42,10 +43,12 @@ import io.element.android.libraries.designsystem.theme.mentionPillText
 import io.element.android.libraries.matrix.api.core.SessionId
 import io.element.android.libraries.matrix.api.permalink.PermalinkData
 import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import kotlinx.collections.immutable.persistentListOf
 
 @Stable
 class MentionSpanProvider(
     private val currentSessionId: SessionId,
+    private val permalinkParser: PermalinkParser,
     private var currentUserTextColor: Int = 0,
     private var currentUserBackgroundColor: Int = Color.WHITE,
     private var otherTextColor: Int = 0,
@@ -73,7 +76,7 @@ class MentionSpanProvider(
     }
 
     fun getMentionSpanFor(text: String, url: String): MentionSpan {
-        val permalinkData = PermalinkParser.parse(url)
+        val permalinkData = permalinkParser.parse(url)
         val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
         return when {
             permalinkData is PermalinkData.UserLink -> {
@@ -112,9 +115,15 @@ class MentionSpanProvider(
 }
 
 @Composable
-fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider {
+fun rememberMentionSpanProvider(
+    currentUserId: SessionId,
+    permalinkParser: PermalinkParser,
+): MentionSpanProvider {
     val provider = remember(currentUserId) {
-        MentionSpanProvider(currentUserId)
+        MentionSpanProvider(
+            currentSessionId = currentUserId,
+            permalinkParser = permalinkParser,
+        )
     }
     provider.setup()
     return provider
@@ -123,7 +132,26 @@ fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider {
 @PreviewsDayNight
 @Composable
 internal fun MentionSpanPreview() {
-    val provider = rememberMentionSpanProvider(SessionId("@me:matrix.org"))
+    val provider = rememberMentionSpanProvider(
+        currentUserId = SessionId("@me:matrix.org"),
+        permalinkParser = object : PermalinkParser {
+            override fun parse(uriString: String): PermalinkData {
+                return when (uriString) {
+                    "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink("@me:matrix.org")
+                    "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink("@other:matrix.org")
+                    "https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink(
+                        roomIdOrAlias = "#room:matrix.org",
+                        isRoomAlias = true,
+                        eventId = null,
+                        viaParameters = persistentListOf(),
+                    )
+                    else -> TODO()
+                }
+            }
+
+            override fun parse(uri: Uri): PermalinkData = TODO()
+        },
+    )
     ElementPreview {
         provider.setup()
 
diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/OnlyFallbackPermalinkParser.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/OnlyFallbackPermalinkParser.kt
new file mode 100644
index 00000000000..50903a910d1
--- /dev/null
+++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/OnlyFallbackPermalinkParser.kt
@@ -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)
+    }
+}
diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
index 827038785cc..7bf32613715 100644
--- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
+++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
@@ -89,6 +89,7 @@ class RoomListScreen(
             ),
             profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
             stateContentFormatter = StateContentFormatter(stringProvider),
+            permalinkParser = OnlyFallbackPermalinkParser(),
         ),
     )
     private val presenter = RoomListPresenter(


Test patch:
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 30413d1b9fc..6cceb6b5b54 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -77,6 +77,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
 import io.element.android.libraries.matrix.test.FakeMatrixClient
 import io.element.android.libraries.matrix.test.core.aBuildMeta
 import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
 import io.element.android.libraries.matrix.test.room.aRoomInfo
 import io.element.android.libraries.matrix.test.room.aRoomMember
@@ -725,6 +727,8 @@ class MessagesPresenterTest {
             richTextEditorStateFactory = TestRichTextEditorStateFactory(),
             permissionsPresenterFactory = permissionsPresenterFactory,
             currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
+            permalinkParser = FakePermalinkParser(),
+            permalinkBuilder = FakePermalinkBuilder(),
         )
         val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
             this,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index b931dc9860e..e176e20805f 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -478,6 +478,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
     onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
     onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
     onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
+    onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
     onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
     onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
     onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
@@ -492,6 +493,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
                 onRoomDetailsClicked = onRoomDetailsClicked,
                 onEventClicked = onEventClicked,
                 onUserDataClicked = onUserDataClicked,
+                onLinkClicked = onLinkClicked,
                 onPreviewAttachments = onPreviewAttachments,
                 onSendLocationClicked = onSendLocationClicked,
                 onCreatePollClicked = onCreatePollClicked,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
index 562424474c5..db056dea19a 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
 import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
 import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
 import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
 import io.element.android.tests.testutils.testCoroutineDispatchers
 import kotlinx.coroutines.test.TestScope
@@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
                     fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
                     featureFlagService = FakeFeatureFlagService(),
                     htmlConverterProvider = FakeHtmlConverterProvider(),
+                    permalinkParser = FakePermalinkParser(),
                 ),
                 redactedMessageFactory = TimelineItemContentRedactedFactory(),
                 stickerFactory = TimelineItemContentStickerFactory(
@@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
             ),
             matrixClient = matrixClient,
             lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
+            permalinkParser = FakePermalinkParser(),
         ),
         virtualItemFactory = TimelineItemVirtualFactory(
             daySeparatorFactory = TimelineItemDaySeparatorFactory(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
index 5e980514cb4..290f2971173 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.TransactionId
 import io.element.android.libraries.matrix.api.media.ImageInfo
 import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
 import io.element.android.libraries.matrix.api.room.Mention
@@ -60,6 +61,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
 import io.element.android.libraries.matrix.test.A_USER_ID_4
 import io.element.android.libraries.matrix.test.A_USER_NAME
 import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
 import io.element.android.libraries.matrix.test.room.aRoomMember
 import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -805,7 +808,14 @@ class MessageComposerPresenterTest {
 
     @Test
     fun `present - insertMention`() = runTest {
-        val presenter = createPresenter(this)
+        val presenter = createPresenter(
+            coroutineScope = this,
+            permalinkBuilder = FakePermalinkBuilder(
+                result = {
+                    Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
+                }
+            )
+        )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
@@ -941,6 +951,7 @@ class MessageComposerPresenterTest {
         mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
         snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
         permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+        permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
     ) = MessageComposerPresenter(
         coroutineScope,
         room,
@@ -955,6 +966,8 @@ class MessageComposerPresenterTest {
         TestRichTextEditorStateFactory(),
         currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
         permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
+        permalinkParser = FakePermalinkParser(),
+        permalinkBuilder = permalinkBuilder,
     )
 
     private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
index 276544a0579..7411ceda1a0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
 import androidx.compose.ui.test.junit4.createComposeRule
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest {
 
     @Test
     fun `calling provide without calling Update first should throw an exception`() {
-        val provider = DefaultHtmlConverterProvider()
+        val provider = DefaultHtmlConverterProvider(
+            permalinkParser = FakePermalinkParser(),
+        )
 
         val exception = runCatching { provider.provide() }.exceptionOrNull()
 
@@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest {
 
     @Test
     fun `calling provide after calling Update first should return an HtmlConverter`() {
-        val provider = DefaultHtmlConverterProvider()
+        val provider = DefaultHtmlConverterProvider(
+            permalinkParser = FakePermalinkParser(),
+        )
         composeTestRule.setContent {
             CompositionLocalProvider(LocalInspectionMode provides true) {
                 provider.Update(currentUserId = A_USER_ID)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
index 78cd671e3f9..44fb6270ae0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
@@ -45,6 +45,7 @@ class TimelineViewTest {
                 typingNotificationState = aTypingNotificationState(),
                 roomName = null,
                 onUserDataClicked = EnsureNeverCalledWithParam(),
+                onLinkClicked = EnsureNeverCalledWithParam(),
                 onMessageClicked = EnsureNeverCalledWithParam(),
                 onMessageLongClicked = EnsureNeverCalledWithParam(),
                 onTimestampClicked = EnsureNeverCalledWithParam(),
@@ -72,6 +73,7 @@ class TimelineViewTest {
                 typingNotificationState = aTypingNotificationState(),
                 roomName = null,
                 onUserDataClicked = EnsureNeverCalledWithParam(),
+                onLinkClicked = EnsureNeverCalledWithParam(),
                 onMessageClicked = EnsureNeverCalledWithParam(),
                 onMessageLongClicked = EnsureNeverCalledWithParam(),
                 onTimestampClicked = EnsureNeverCalledWithParam(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 98953f48272..6e475707adc 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
 import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
 import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
 import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
 import kotlinx.collections.immutable.persistentListOf
@@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest {
         fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
         featureFlagService = featureFlagService,
         htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
+        permalinkParser = FakePermalinkParser(),
     )
 
     private fun createStickerContent(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
index f07a73fd842..bf287341ad3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
@@ -26,14 +26,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi
 import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
 import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import org.junit.Test
 
 class InReplyToDetailTest {
     @Test
     fun `map - with a not ready InReplyTo does not work`() {
-        assertThat(InReplyTo.Pending.map()).isNull()
-        assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull()
-        assertThat(InReplyTo.Error.map()).isNull()
+        assertThat(
+            InReplyTo.Pending.map(
+                permalinkParser = FakePermalinkParser()
+            )
+        ).isNull()
+        assertThat(
+            InReplyTo.NotLoaded(AN_EVENT_ID).map(
+                permalinkParser = FakePermalinkParser()
+            )
+        ).isNull()
+        assertThat(
+            InReplyTo.Error.map(
+                permalinkParser = FakePermalinkParser()
+            )
+        ).isNull()
     }
 
     @Test
@@ -48,7 +61,9 @@ class InReplyToDetailTest {
                 change = MembershipChange.INVITED,
             )
         )
-        val inReplyToDetails = inReplyTo.map()
+        val inReplyToDetails = inReplyTo.map(
+            permalinkParser = FakePermalinkParser()
+        )
         assertThat(inReplyToDetails).isNotNull()
         assertThat(inReplyToDetails?.textContent).isNull()
     }
@@ -74,7 +89,11 @@ class InReplyToDetailTest {
                 )
             )
         )
-        assertThat(inReplyTo.map()?.textContent).isEqualTo("Hello!")
+        assertThat(
+            inReplyTo.map(
+                permalinkParser = FakePermalinkParser()
+            )?.textContent
+        ).isEqualTo("Hello!")
     }
 
     @Test
@@ -95,6 +114,10 @@ class InReplyToDetailTest {
                 )
             )
         )
-        assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**")
+        assertThat(
+            inReplyTo.map(
+                permalinkParser = FakePermalinkParser()
+            )?.textContent
+        ).isEqualTo("**Hello!**")
     }
 }
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index a0a17b3465a..158eeec20df 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
 import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
 import io.element.android.libraries.matrix.test.A_USER_ID
 import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.test.timeline.aPollContent
 import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
 import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@@ -78,7 +79,8 @@ class DefaultRoomLastMessageFormatterTest {
             sp = AndroidStringProvider(context.resources),
             roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
             profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
-            stateContentFormatter = StateContentFormatter(stringProvider)
+            stateContentFormatter = StateContentFormatter(stringProvider),
+            permalinkParser = FakePermalinkParser(),
         )
     }
 
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
similarity index 71%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
index cbf1bffc88c..7401ac7c2e4 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
 
 import android.net.Uri
 import com.google.common.truth.Truth.assertThat
@@ -23,34 +23,34 @@ import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 
 @RunWith(RobolectricTestRunner::class)
-class MatrixToConverterTest {
+class DefaultMatrixToConverterTest {
     @Test
     fun `converting a matrix-to url does nothing`() {
         val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
-        assertThat(MatrixToConverter.convert(url)).isEqualTo(url)
+        assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(url)
     }
 
     @Test
     fun `converting a url with a supported room path returns a matrix-to url`() {
         val url = Uri.parse("https://riot.im/develop/#/room/#element-android:matrix.org")
-        assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
+        assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
     }
 
     @Test
     fun `converting a url with a supported user path returns a matrix-to url`() {
         val url = Uri.parse("https://riot.im/develop/#/user/@test:matrix.org")
-        assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org"))
+        assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org"))
     }
 
     @Test
     fun `converting a url with a supported group path returns a matrix-to url`() {
         val url = Uri.parse("https://riot.im/develop/#/group/+group:matrix.org")
-        assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org"))
+        assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org"))
     }
 
     @Test
     fun `converting an unsupported url returns null`() {
         val url = Uri.parse("https://element.io/")
-        assertThat(MatrixToConverter.convert(url)).isNull()
+        assertThat(DefaultMatrixToConverter().convert(url)).isNull()
     }
 }
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
similarity index 70%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
index 2b5b29e084c..c861b3105cb 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
 
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.androidutils.metadata.withReleaseBehavior
@@ -23,18 +23,18 @@ import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.tests.testutils.assertThrowsInDebug
 import org.junit.Test
 
-class PermalinkBuilderTest {
+class DefaultPermalinkBuilderTest {
     fun `building a permalink for an invalid user id throws when verifying the id`() {
         assertThrowsInDebug {
             val userId = UserId("some invalid user id")
-            PermalinkBuilder.permalinkForUser(userId)
+            DefaultPermalinkBuilder().permalinkForUser(userId)
         }
     }
 
     fun `building a permalink for an invalid room id throws when verifying the id`() {
         assertThrowsInDebug {
             val roomId = RoomId("some invalid room id")
-            PermalinkBuilder.permalinkForRoomId(roomId)
+            DefaultPermalinkBuilder().permalinkForRoomId(roomId)
         }
     }
 
@@ -42,7 +42,7 @@ class PermalinkBuilderTest {
     fun `building a permalink for an invalid user id returns failure when not verifying the id`() {
         withReleaseBehavior {
             val userId = UserId("some invalid user id")
-            assertThat(PermalinkBuilder.permalinkForUser(userId).isFailure).isTrue()
+            assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).isFailure).isTrue()
         }
     }
 
@@ -50,31 +50,31 @@ class PermalinkBuilderTest {
     fun `building a permalink for an invalid room id returns failure when not verifying the id`() {
         withReleaseBehavior {
             val roomId = RoomId("some invalid room id")
-            assertThat(PermalinkBuilder.permalinkForRoomId(roomId).isFailure).isTrue()
+            assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).isFailure).isTrue()
         }
     }
 
     @Test
     fun `building a permalink for an invalid room alias returns failure`() {
         val roomAlias = "an invalid room alias"
-        assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).isFailure).isTrue()
+        assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).isFailure).isTrue()
     }
 
     @Test
     fun `building a permalink for a valid user id returns a matrix-to url`() {
         val userId = UserId("@user:matrix.org")
-        assertThat(PermalinkBuilder.permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org")
+        assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org")
     }
 
     @Test
     fun `building a permalink for a valid room id returns a matrix-to url`() {
         val roomId = RoomId("!aBCdEFG1234:matrix.org")
-        assertThat(PermalinkBuilder.permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org")
+        assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org")
     }
 
     @Test
     fun `building a permalink for a valid room alias returns a matrix-to url`() {
         val roomAlias = "#room:matrix.org"
-        assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org")
+        assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org")
     }
 }
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
similarity index 70%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
index 66cdaf88eeb..1e9e3bc2dc9 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,44 +14,60 @@
  * limitations under the License.
  */
 
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
 
 import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
 import kotlinx.collections.immutable.persistentListOf
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 
 @RunWith(RobolectricTestRunner::class)
-class PermalinkParserTest {
+class DefaultPermalinkParserTest {
     @Test
     fun `parsing an invalid url returns a fallback link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://element.io"
-        assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+        assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
     }
 
     @Test
     fun `parsing an invalid url with the right path but no content returns a fallback link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/user"
-        assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+        assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
     }
 
     @Test
     fun `parsing an invalid url with the right path but empty content returns a fallback link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/user/"
-        assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+        assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
     }
 
     @Test
     fun `parsing an invalid url with the right path but invalid content returns a fallback link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/user/some%20user!"
-        assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+        assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
     }
 
     @Test
     fun `parsing a valid user url returns a user link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/user/@test:matrix.org"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.UserLink(
                 userId = "@test:matrix.org"
             )
@@ -60,8 +76,11 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a valid room id url returns a room link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/!aBCD1234:matrix.org"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomLink(
                 roomIdOrAlias = "!aBCD1234:matrix.org",
                 isRoomAlias = false,
@@ -73,8 +92,11 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a valid room id with event id url returns a room link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomLink(
                 roomIdOrAlias = "!aBCD1234:matrix.org",
                 isRoomAlias = false,
@@ -86,8 +108,11 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a valid room id with and invalid event id url returns a room link with no event id`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/1234567890abcdef:matrix.org"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomLink(
                 roomIdOrAlias = "!aBCD1234:matrix.org",
                 isRoomAlias = false,
@@ -99,8 +124,11 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a valid room id with event id and via parameters url returns a room link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org?via=matrix.org&via=matrix.com"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomLink(
                 roomIdOrAlias = "!aBCD1234:matrix.org",
                 isRoomAlias = false,
@@ -112,8 +140,11 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a valid room alias url returns a room link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/#element-android:matrix.org"
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomLink(
                 roomIdOrAlias = "#element-android:matrix.org",
                 isRoomAlias = true,
@@ -125,6 +156,9 @@ class PermalinkParserTest {
 
     @Test
     fun `parsing a url with an invalid signurl returns a fallback link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         // This url has no private key
         val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
             "?email=testuser%40element.io" +
@@ -135,11 +169,14 @@ class PermalinkParserTest {
             "&guest_access_token=" +
             "&guest_user_id=" +
             "&room_type="
-        assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+        assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
     }
 
     @Test
     fun `parsing a url with signurl returns a room email invite link`() {
+        val sut = DefaultPermalinkParser(
+            matrixToConverter = DefaultMatrixToConverter(),
+        )
         val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
             "?email=testuser%40element.io" +
             "&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3Da_token%26private_key%3Da_private_key" +
@@ -149,7 +186,7 @@ class PermalinkParserTest {
             "&guest_access_token=" +
             "&guest_user_id=" +
             "&room_type="
-        assertThat(PermalinkParser.parse(url)).isEqualTo(
+        assertThat(sut.parse(url)).isEqualTo(
             PermalinkData.RoomEmailInviteLink(
                 roomId = "!aBCDEF12345:matrix.org",
                 email = "testuser@element.io",
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
new file mode 100644
index 00000000000..4e6d3375e1d
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.permalink
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+
+class FakePermalinkBuilder(
+    private val result: () -> Result<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()
+    }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
new file mode 100644
index 00000000000..f046eadf93f
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.permalink
+
+import android.net.Uri
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+
+class FakePermalinkParser(
+    private var result: () -> PermalinkData = { 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")
+    }
+}
diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
index 704b87c593a..ab83f77e661 100644
--- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
+++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
@@ -16,9 +16,13 @@
 
 package io.element.android.libraries.matrixui.messages
 
+import android.net.Uri
 import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
 import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -33,7 +37,7 @@ class ToHtmlDocumentTest {
             body = "Hello world"
         )
 
-        val document = body.toHtmlDocument()
+        val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
 
         assertThat(document).isNull()
     }
@@ -45,7 +49,7 @@ class ToHtmlDocumentTest {
             body = "<p>Hello world</p>"
         )
 
-        val document = body.toHtmlDocument()
+        val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
         assertThat(document).isNotNull()
         assertThat(document?.text()).isEqualTo("Hello world")
     }
@@ -57,7 +61,10 @@ class ToHtmlDocumentTest {
             body = "<p>Hello world</p>"
         )
 
-        val document = body.toHtmlDocument(prefix = "@Jorge:")
+        val document = body.toHtmlDocument(
+            permalinkParser = FakePermalinkParser(),
+            prefix = "@Jorge:"
+        )
         assertThat(document).isNotNull()
         assertThat(document?.text()).isEqualTo("@Jorge: Hello world")
     }
@@ -69,7 +76,13 @@ class ToHtmlDocumentTest {
             body = "Hey <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!")
     }
 
@@ -80,7 +93,13 @@ class ToHtmlDocumentTest {
             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!")
     }
 
@@ -91,7 +110,13 @@ class ToHtmlDocumentTest {
             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!")
     }
 }
diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
index 7c610755844..74e43a9e39a 100644
--- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
+++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
 import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.ui.messages.toPlainText
 import org.jsoup.Jsoup
 import org.junit.Test
@@ -59,7 +60,7 @@ class ToPlainTextTest {
                 <br />
             """.trimIndent()
         )
-        assertThat(formattedBody.toPlainText()).isEqualTo(
+        assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
             """
             Hello world 
             • This is an unordered list.
@@ -79,7 +80,7 @@ class ToPlainTextTest {
                 <br />
             """.trimIndent()
         )
-        assertThat(formattedBody.toPlainText()).isNull()
+        assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull()
     }
 
     @Test
@@ -96,7 +97,7 @@ class ToPlainTextTest {
                 """.trimIndent()
             )
         )
-        assertThat(messageType.toPlainText()).isEqualTo(
+        assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
             """
             Hello world 
             • This is an unordered list.
@@ -119,6 +120,6 @@ class ToPlainTextTest {
                 """.trimIndent()
             )
         )
-        assertThat(messageType.toPlainText()).isEqualTo("This is the fallback text")
+        assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text")
     }
 }
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt
index 4e491a0def0..b2f954091e9 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
 import io.element.android.libraries.matrix.test.FakeMatrixClient
 import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
 import io.element.android.libraries.matrix.test.notification.FakeNotificationService
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
 import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@@ -549,6 +550,7 @@ class NotifiableEventResolverTest {
             matrixClientProvider = matrixClientProvider,
             notificationMediaRepoFactory = notificationMediaRepoFactory,
             context = context,
+            permalinkParser = FakePermalinkParser(),
         )
     }
 
diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
index c8281cca0ef..475b8798953 100644
--- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
+++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
@@ -18,9 +18,12 @@ package io.element.android.libraries.textcomposer.impl.mentions
 
 import android.graphics.Color
 import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
 import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
 import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.collections.immutable.persistentListOf
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -35,8 +38,10 @@ class MentionSpanProviderTest {
     private val otherColor = Color.BLUE
     private val currentUserId = A_SESSION_ID
 
+    private val permalinkParser = FakePermalinkParser()
     private val mentionSpanProvider = MentionSpanProvider(
         currentSessionId = currentUserId,
+        permalinkParser = permalinkParser,
         currentUserBackgroundColor = myUserColor,
         currentUserTextColor = myUserColor,
         otherBackgroundColor = otherColor,
@@ -45,6 +50,7 @@ class MentionSpanProviderTest {
 
     @Test
     fun `getting mention span for current user should return a MentionSpan with custom colors`() {
+        permalinkParser.givenResult(PermalinkData.UserLink(currentUserId.value))
         val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}")
         assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
         assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
@@ -52,6 +58,7 @@ class MentionSpanProviderTest {
 
     @Test
     fun `getting mention span for other user should return a MentionSpan with normal colors`() {
+        permalinkParser.givenResult(PermalinkData.UserLink("@other:matrix.org"))
         val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org")
         assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
         assertThat(mentionSpan.textColor).isEqualTo(otherColor)
@@ -59,6 +66,14 @@ class MentionSpanProviderTest {
 
     @Test
     fun `getting mention span for a room should return a MentionSpan with normal colors`() {
+        permalinkParser.givenResult(
+            PermalinkData.RoomLink(
+                roomIdOrAlias = "#room:matrix.org",
+                isRoomAlias = true,
+                eventId = null,
+                viaParameters = persistentListOf(),
+            )
+        )
         val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
         assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
         assertThat(mentionSpan.textColor).isEqualTo(otherColor)
@@ -66,6 +81,14 @@ class MentionSpanProviderTest {
 
     @Test
     fun `getting mention span for @room should return a MentionSpan with normal colors`() {
+        permalinkParser.givenResult(
+            PermalinkData.RoomLink(
+                roomIdOrAlias = "#",
+                isRoomAlias = true,
+                eventId = null,
+                viaParameters = persistentListOf(),
+            )
+        )
         val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
         assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
         assertThat(mentionSpan.textColor).isEqualTo(otherColor)