Add feature flag to temporary disable sending caption by default in production

Closes #3950

Android friend of https://github.com/element-hq/element-x-ios/pull/3560

Add feature flag to disable caption sending / adding
The legacy applications cannot render captions on the timeline, so we're going to add a temporary disabled feature flag.

When the flag is off:
- [x] hide the caption form on the media preview screen
- [x] hide the "Add caption" from the message menu

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index e228f3eb736..f2b97769c03 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -37,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa
 import io.element.android.features.messages.impl.timeline.model.event.canReact
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.di.RoomScope
+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.room.MatrixRoom
 import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@@ -60,6 +62,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
     private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
     private val room: MatrixRoom,
     private val userSendFailureFactory: VerifiedUserSendFailureFactory,
+    private val featureFlagService: FeatureFlagService,
 ) : ActionListPresenter {
     @AssistedFactory
     @ContributesBinding(RoomScope::class)
@@ -134,7 +137,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
         }
     }

-    private fun buildActions(
+    private suspend fun buildActions(
         timelineItem: TimelineItem.Event,
         usersEventPermissions: UserEventPermissions,
         isDeveloperModeEnabled: Boolean,
@@ -157,7 +160,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
                 if (timelineItem.content is TimelineItemEventContentWithAttachment) {
                     // Caption
                     if (timelineItem.content.caption == null) {
-                        add(TimelineItemAction.AddCaption)
+                        if (featureFlagService.isFeatureEnabled(FeatureFlags.MediaCaptionCreation)) {
+                            add(TimelineItemAction.AddCaption)
+                        }
                     } else {
                         add(TimelineItemAction.EditCaption)
                         add(TimelineItemAction.RemoveCaption)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index 69d28d8f202..606e91863d3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.attachments.preview
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -46,7 +47,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
     private val mediaSender: MediaSender,
     private val permalinkBuilder: PermalinkBuilder,
     private val temporaryUriDeleter: TemporaryUriDeleter,
-    private val featureFlagsService: FeatureFlagService,
+    private val featureFlagService: FeatureFlagService,
 ) : Presenter<AttachmentsPreviewState> {
     @AssistedFactory
     interface Factory {
@@ -72,6 +73,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
         val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }

         val userSentAttachment = remember { mutableStateOf(false) }
+        val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)

         val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
         LaunchedEffect(Unit) {
@@ -112,7 +114,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
         fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
             when (attachmentsPreviewEvents) {
                 is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch {
-                    val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
+                    val useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
                     userSentAttachment.value = true
                     val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue
                     sendActionState.value = if (instantSending) {
@@ -142,6 +144,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
             attachment = attachment,
             sendActionState = sendActionState.value,
             textEditorState = textEditorState,
+            allowCaption = allowCaption,
             eventSink = ::handleEvents
         )
     }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
index 0e9724c4606..0878b52f803 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
@@ -15,11 +15,9 @@ data class AttachmentsPreviewState(
     val attachment: Attachment,
     val sendActionState: SendActionState,
     val textEditorState: TextEditorState,
+    val allowCaption: Boolean,
     val eventSink: (AttachmentsPreviewEvents) -> Unit
-) {
-    // Keep the val to eventually set to false for some mimetypes.
-    val allowCaption: Boolean = true
-}
+)

 @Immutable
 sealed interface SendActionState {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
index 1c55245e469..f7e215fa222 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
@@ -29,6 +29,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
             anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
             anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
             anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
+            anAttachmentsPreviewState(allowCaption = false),
         )
 }

@@ -36,11 +37,13 @@ fun anAttachmentsPreviewState(
     mediaInfo: MediaInfo = anImageMediaInfo(),
     textEditorState: TextEditorState = aTextEditorStateMarkdown(),
     sendActionState: SendActionState = SendActionState.Idle,
+    allowCaption: Boolean = true,
 ) = AttachmentsPreviewState(
     attachment = Attachment.Media(
         localMedia = LocalMedia("file://path".toUri(), mediaInfo),
     ),
     sendActionState = sendActionState,
     textEditorState = textEditorState,
+    allowCaption = allowCaption,
     eventSink = {}
 )
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 490c12ebe0d..42d9f309207 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -140,4 +140,11 @@ enum class FeatureFlags(
         defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
         isFinished = false,
     ),
+    MediaCaptionCreation(
+        key = "feature.media_caption_creation",
+        title = "Allow creation of media captions",
+        description = null,
+        defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
+        isFinished = false,
+    ),
 }

diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index b79468f8200..aedd0cdc14b 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -26,6 +26,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
 import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
 import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
 import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -558,6 +560,57 @@ class ActionListPresenterTest {
         }
     }

+    @Test
+    fun `present - compute for a media item - caption disabled`() = runTest {
+        val presenter = createActionListPresenter(
+            isDeveloperModeEnabled = true,
+            isPinFeatureEnabled = true,
+            allowCaption = false,
+        )
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            val initialState = awaitItem()
+            val messageEvent = aMessageEvent(
+                isMine = true,
+                isEditable = true,
+                content = aTimelineItemImageContent(),
+            )
+            initialState.eventSink.invoke(
+                ActionListEvents.ComputeForMessage(
+                    event = messageEvent,
+                    userEventPermissions = aUserEventPermissions(
+                        canRedactOwn = true,
+                        canRedactOther = false,
+                        canSendMessage = true,
+                        canSendReaction = true,
+                        canPinUnpin = true,
+                    ),
+                )
+            )
+            val successState = awaitItem()
+            assertThat(successState.target).isEqualTo(
+                ActionListState.Target.Success(
+                    event = messageEvent,
+                    displayEmojiReactions = true,
+                    verifiedUserSendFailure = VerifiedUserSendFailure.None,
+                    actions = persistentListOf(
+                        TimelineItemAction.Reply,
+                        TimelineItemAction.Forward,
+                        // Not here
+                        // TimelineItemAction.AddCaption,
+                        TimelineItemAction.Pin,
+                        TimelineItemAction.CopyLink,
+                        TimelineItemAction.ViewSource,
+                        TimelineItemAction.Redact,
+                    )
+                )
+            )
+            initialState.eventSink.invoke(ActionListEvents.Clear)
+            assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+        }
+    }
+
     @Test
     fun `present - compute for a media with caption item`() = runTest {
         val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@@ -1151,6 +1204,7 @@ private fun createActionListPresenter(
     isDeveloperModeEnabled: Boolean,
     isPinFeatureEnabled: Boolean,
     room: MatrixRoom = FakeMatrixRoom(),
+    allowCaption: Boolean = true,
 ): ActionListPresenter {
     val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
     return DefaultActionListPresenter(
@@ -1158,6 +1212,11 @@ private fun createActionListPresenter(
         appPreferencesStore = preferencesStore,
         isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
         room = room,
-        userSendFailureFactory = VerifiedUserSendFailureFactory(room)
+        userSendFailureFactory = VerifiedUserSendFailureFactory(room),
+        featureFlagService = FakeFeatureFlagService(
+            initialState = mapOf(
+                FeatureFlags.MediaCaptionCreation.key to allowCaption,
+            ),
+        )
     )
 }
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 48e5c208b6c..095b770f082 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -64,6 +64,28 @@ class AttachmentsPreviewPresenterTest {

     private val mockMediaUrl: Uri = mockk("localMediaUri")

+    @Test
+    fun `present - initial state`() = runTest {
+        createAttachmentsPreviewPresenter().test {
+           skipItems(1)
+            val initialState = awaitItem()
+            assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+            assertThat(initialState.allowCaption).isTrue()
+        }
+    }
+
+    @Test
+    fun `present - initial state - caption not allowed`() = runTest {
+        createAttachmentsPreviewPresenter(
+            allowCaption = false,
+        ).test {
+            skipItems(1)
+            val initialState = awaitItem()
+            assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+            assertThat(initialState.allowCaption).isFalse()
+        }
+    }
+
     @Test
     fun `present - send media success scenario`() = runTest {
         val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
@@ -420,6 +442,7 @@ class AttachmentsPreviewPresenterTest {
         temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
         onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
         mediaUploadOnSendQueueEnabled: Boolean = true,
+        allowCaption: Boolean = true,
     ): AttachmentsPreviewPresenter {
         return AttachmentsPreviewPresenter(
             attachment = aMediaAttachment(localMedia),
@@ -427,8 +450,11 @@ class AttachmentsPreviewPresenterTest {
             mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
             permalinkBuilder = permalinkBuilder,
             temporaryUriDeleter = temporaryUriDeleter,
-            featureFlagsService = FakeFeatureFlagService(
-                initialState = mapOf(FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled),
+            featureFlagService = FakeFeatureFlagService(
+                initialState = mapOf(
+                    FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
+                    FeatureFlags.MediaCaptionCreation.key to allowCaption,
+                ),
             )
         )
     }
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png
new file mode 100644
index 00000000000..6920a089ef4
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
+size 392923


<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Content

<!-- Describe shortly what has been changed -->
Enable all notification actions: quick reply, accept/decline invite, mark as read from notification.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #3908
Closes #1966

## 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|
|-|-|
|||
 -->

![Notification action](https://github.com/user-attachments/assets/8e417828-8346-4252-b14d-6c09967472e8)

## Tests

<!-- Explain how you tested your development -->

- Receive notification for message or for room invite and see that they now have actions.

## 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 title will be used in the release note, it clearly define what will change for the user
- [ ] 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

bring "mark as read" button on the notification back
### Your use case

Please bring "mark as read" button on the notification back.

![image](https://github.com/user-attachments/assets/18f2a8fa-104c-4ee4-8d4f-1d1cca709a0c)

### Have you considered any alternatives?

_No response_

### Additional context

_No response_

### Are you willing to provide a PR?

No
have quick reply on notifs that when you reply via the notif it replies to the message in the room
### Your use case

#### What would you like to do?
when you reply to a notification of a message it replies to the message in element x
#### Why would you like to do it?
its nice to quickly reply to a message without opening the app and possible getting sidetracked by other rooms or conversations
#### How would you like to achieve it?
have a quick reply option when a notification gets sent like in ele android, that replies to the message in the notification, which isnt how ele android does it currently

### Have you considered any alternatives?

you could just send the message while not replying but that can easily cause confusion in a busy room

### Additional context

_No response_

### Are you willing to provide a PR?

No

Thanks for creating the issue. We are waiting for a product decision to restore notification actions.
I agree it would be nice to support this. It's currently disabled, waiting for a product decision.
Would be great
See also #1547

diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
index c5f605e5fe3..83ea9e3b77d 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
@@ -11,14 +11,20 @@ import android.graphics.Color
 import androidx.annotation.ColorInt

 object NotificationConfig {
-    // TODO EAx Implement and set to true at some point
-    const val SUPPORT_MARK_AS_READ_ACTION = false
+    /**
+     * If set to true, the notification will have a "Mark as read" action.
+     */
+    const val SHOW_MARK_AS_READ_ACTION = true

-    // TODO EAx Implement and set to true at some point
-    const val SUPPORT_JOIN_DECLINE_INVITE = false
+    /**
+     * If set to true, the notification for invitation will have two actions to accept or decline the invite.
+     */
+    const val SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS = true

-    // TODO EAx Implement and set to true at some point
-    const val SUPPORT_QUICK_REPLY_ACTION = false
+    /**
+     * If set to true, the notification will have a "Quick reply" action, allow to compose and send a message to the room.
+     */
+    const val SHOW_QUICK_REPLY_ACTION = true

     @ColorInt
     val NOTIFICATION_ACCENT_COLOR: Int = Color.parseColor("#FF0DBD8B")
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
index 559322eaaef..6788410468a 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
@@ -230,10 +230,8 @@ class DefaultNotificationCreator @Inject constructor(
             .setSmallIcon(smallIcon)
             .setColor(accentColor)
             .apply {
-                if (NotificationConfig.SUPPORT_JOIN_DECLINE_INVITE) {
-                    addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
-                    addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
-                }
+                addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
+                addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
                 // Build the pending intent for when the notification is clicked
                 setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))

diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt
index ee3d392c1fb..9b2cf64a7d4 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt
@@ -11,12 +11,14 @@ import android.app.PendingIntent
 import android.content.Context
 import android.content.Intent
 import androidx.core.app.NotificationCompat
+import io.element.android.appconfig.NotificationConfig
 import io.element.android.libraries.androidutils.uri.createIgnoredUri
 import io.element.android.libraries.di.ApplicationContext
 import io.element.android.libraries.push.impl.R
 import io.element.android.libraries.push.impl.notifications.NotificationActionIds
 import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
+import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.services.toolbox.api.strings.StringProvider
 import io.element.android.services.toolbox.api.systemclock.SystemClock
 import javax.inject.Inject
@@ -27,8 +29,8 @@ class AcceptInvitationActionFactory @Inject constructor(
     private val stringProvider: StringProvider,
     private val clock: SystemClock,
 ) {
-    // offer to type a quick accept button
-    fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action {
+    fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? {
+        if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null
         val sessionId = inviteNotifiableEvent.sessionId.value
         val roomId = inviteNotifiableEvent.roomId.value
         val intent = Intent(context, NotificationBroadcastReceiver::class.java)
@@ -44,7 +46,7 @@ class AcceptInvitationActionFactory @Inject constructor(
         )
         return NotificationCompat.Action.Builder(
             R.drawable.vector_notification_accept_invitation,
-            stringProvider.getString(R.string.notification_invitation_action_join),
+            stringProvider.getString(CommonStrings.action_accept),
             pendingIntent
         ).build()
     }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt
index b77e36cfc00..e6a2594bb83 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt
@@ -29,7 +29,7 @@ class MarkAsReadActionFactory @Inject constructor(
     private val clock: SystemClock,
 ) {
     fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? {
-        if (!NotificationConfig.SUPPORT_MARK_AS_READ_ACTION) return null
+        if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
         val sessionId = roomInfo.sessionId.value
         val roomId = roomInfo.roomId.value
         val intent = Intent(context, NotificationBroadcastReceiver::class.java)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
index 3ada849026a..bced0abd904 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
@@ -34,7 +34,7 @@ class QuickReplyActionFactory @Inject constructor(
     private val clock: SystemClock,
 ) {
     fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
-        if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null
+        if (!NotificationConfig.SHOW_QUICK_REPLY_ACTION) return null
         val sessionId = roomInfo.sessionId
         val roomId = roomInfo.roomId
         val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt
index 309acce44cf..d4eb28f1411 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt
@@ -11,12 +11,14 @@ import android.app.PendingIntent
 import android.content.Context
 import android.content.Intent
 import androidx.core.app.NotificationCompat
+import io.element.android.appconfig.NotificationConfig
 import io.element.android.libraries.androidutils.uri.createIgnoredUri
 import io.element.android.libraries.di.ApplicationContext
 import io.element.android.libraries.push.impl.R
 import io.element.android.libraries.push.impl.notifications.NotificationActionIds
 import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
+import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.services.toolbox.api.strings.StringProvider
 import io.element.android.services.toolbox.api.systemclock.SystemClock
 import javax.inject.Inject
@@ -28,6 +30,7 @@ class RejectInvitationActionFactory @Inject constructor(
     private val clock: SystemClock,
 ) {
     fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? {
+        if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null
         val sessionId = inviteNotifiableEvent.sessionId.value
         val roomId = inviteNotifiableEvent.roomId.value
         val intent = Intent(context, NotificationBroadcastReceiver::class.java)
@@ -41,10 +44,9 @@ class RejectInvitationActionFactory @Inject constructor(
             intent,
             PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
         )
-
         return NotificationCompat.Action.Builder(
             R.drawable.vector_notification_reject_invitation,
-            stringProvider.getString(R.string.notification_invitation_action_reject),
+            stringProvider.getString(CommonStrings.action_reject),
             pendingIntent
         ).build()
     }

diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt
index d0255aa4528..44846f80107 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt
@@ -11,12 +11,15 @@ import android.content.Context
 import android.os.Build
 import androidx.core.app.NotificationCompat
 import com.google.common.truth.Truth.assertThat
+import io.element.android.appconfig.NotificationConfig
 import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.matrix.test.A_ROOM_ID
 import io.element.android.libraries.matrix.test.A_TIMESTAMP
 import io.element.android.libraries.matrix.ui.components.aMatrixUser
 import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
 import io.element.android.libraries.matrix.ui.media.MediaRequestData
+import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE
+import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE
 import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator
 import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
 import io.element.android.libraries.push.test.notifications.FakeImageLoader
@@ -156,6 +159,13 @@ class DefaultRoomGroupMessageCreatorTest {
         )
         assertThat(result.number).isEqualTo(2)
         assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10)
+        val actionTitles = result.actions?.map { it.title }
+        assertThat(actionTitles).isEqualTo(
+            listOfNotNull(
+                MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION },
+                QUICK_REPLY_ACTION_TITLE.takeIf { NotificationConfig.SHOW_QUICK_REPLY_ACTION },
+            )
+        )
         assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
     }

@@ -175,7 +185,12 @@ class DefaultRoomGroupMessageCreatorTest {
             imageLoader = fakeImageLoader.getImageLoader(),
             existingNotification = null,
         )
-        assertThat(result.actions).isNull()
+        val actionTitles = result.actions?.map { it.title }
+        assertThat(actionTitles).isEqualTo(
+            listOfNotNull(
+                MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION }
+            )
+        )
         assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
     }

diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt
index 9e2f3c10389..9a25ad2a726 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt
@@ -13,6 +13,7 @@ import android.os.Build
 import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationManagerCompat
 import com.google.common.truth.Truth.assertThat
+import io.element.android.appconfig.NotificationConfig
 import io.element.android.libraries.core.meta.BuildMeta
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
 import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -151,6 +152,13 @@ class DefaultNotificationCreatorTest {
         result.commonAssertions(
             expectedCategory = null,
         )
+        val actionTitles = result.actions?.map { it.title }
+        assertThat(actionTitles).isEqualTo(
+            listOfNotNull(
+                REJECT_INVITATION_ACTION_TITLE.takeIf { NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS },
+                ACCEPT_INVITATION_ACTION_TITLE.takeIf { NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS },
+            )
+        )
     }

     @Test
@@ -271,6 +279,11 @@ class DefaultNotificationCreatorTest {
     }
 }

+const val MARK_AS_READ_ACTION_TITLE = "MarkAsReadAction"
+const val QUICK_REPLY_ACTION_TITLE = "QuickReplyAction"
+const val ACCEPT_INVITATION_ACTION_TITLE = "AcceptInvitationAction"
+const val REJECT_INVITATION_ACTION_TITLE = "RejectInvitationAction"
+
 fun createNotificationCreator(
     context: Context = RuntimeEnvironment.getApplication(),
     buildMeta: BuildMeta = aBuildMeta(),
@@ -291,26 +304,26 @@ fun createNotificationCreator(
         markAsReadActionFactory = MarkAsReadActionFactory(
             context = context,
             actionIds = NotificationActionIds(buildMeta),
-            stringProvider = FakeStringProvider("MarkAsReadActionFactory"),
+            stringProvider = FakeStringProvider(MARK_AS_READ_ACTION_TITLE),
             clock = FakeSystemClock(),
         ),
         quickReplyActionFactory = QuickReplyActionFactory(
             context = context,
             actionIds = NotificationActionIds(buildMeta),
-            stringProvider = FakeStringProvider("QuickReplyActionFactory"),
+            stringProvider = FakeStringProvider(QUICK_REPLY_ACTION_TITLE),
             clock = FakeSystemClock(),
         ),
         bitmapLoader = bitmapLoader,
         acceptInvitationActionFactory = AcceptInvitationActionFactory(
             context = context,
             actionIds = NotificationActionIds(buildMeta),
-            stringProvider = FakeStringProvider("AcceptInvitationActionFactory"),
+            stringProvider = FakeStringProvider(ACCEPT_INVITATION_ACTION_TITLE),
             clock = FakeSystemClock(),
         ),
         rejectInvitationActionFactory = RejectInvitationActionFactory(
             context = context,
             actionIds = NotificationActionIds(buildMeta),
-            stringProvider = FakeStringProvider("RejectInvitationActionFactory"),
+            stringProvider = FakeStringProvider(REJECT_INVITATION_ACTION_TITLE),
             clock = FakeSystemClock(),
         ),
     )


Use formatted captions for images and video

## Content

This PR has the following changes:

- Parse the `caption` and `formattedCaption` in the same way as we do for text messages.
- Allow opening links from the caption UI text component.
- Split the `onClick` action for timeline items between those whose onClick action should be triggered by the whole message bubble and those who should trigger it only from certain UI elements (as the image/video preview in the timeline).

Note there's no way to send captions as either markdown or HTML at the moment from the app, so either you send these from some other client or you'd only be able to test this by sending a URL as part of the plain text caption.

This PR can be reviewed commit-by-commit.

## Motivation and context

Fixes #3850.

## Screenshots / GIFs

No changes in screenshots, I hope.

## Tests

<!-- Explain how you tested your development -->

- Send an image/video with an URL in the caption and some other text.
- Make sure the link in the caption is later clickable in the timeline item.
- Clicking on the image/video preview should open the viewer.
- Clicking on the text part that has no URL shouldn't do anything.
- Clicking on the URL should open it.
- Clicking on any other timeline items should have the same effect as before this patch.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): 14

## 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
- [x] Pull request is based on the develop branch
- [x] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] 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)
- [x] You've made a self review of your PR

URLs are not clickable in media captions
### Steps to reproduce

1. Send a message with a media Captain
2. Make sure the caption contains a URL (https://...) somewhere
3. Observe issue

### Outcome

#### What did you expect?

URLs should be highlighted and clickable, as they are in normal text bodies

#### What happened instead?

The text is entirely plain, unclickable, without an underline nor is it blue

### Your phone model

(Xiaomi) Poco F3

### Operating system version

Android 14

### Application version and app store

Element X 0.7.4 Nightly, b097e963

### Homeserver

bark.lgbt

### Will you send logs?

Yes

### Are you willing to provide a PR?

No

Duplicate of #3529 (although this issue is better worded)
> Duplicate of #3529 (although this issue is better worded)

oh I didn't catch that as I searched for "URL" not something like "link" :woozy_face:

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 bf76f208e38..9fd823445f8 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
@@ -212,7 +212,7 @@ class MessagesNode @AssistedInject constructor(
                 state = state,
                 onBackClick = this::navigateUp,
                 onRoomDetailsClick = this::onRoomDetailsClick,
-                onEventClick = this::onEventClick,
+                onEventContentClick = this::onEventClick,
                 onPreviewAttachments = this::onPreviewAttachments,
                 onUserDataClick = this::onUserDataClick,
                 onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
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 e5d73fbed60..3e2918b3ceb 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
@@ -114,7 +114,7 @@ fun MessagesView(
     state: MessagesState,
     onBackClick: () -> Unit,
     onRoomDetailsClick: () -> Unit,
-    onEventClick: (event: TimelineItem.Event) -> Boolean,
+    onEventContentClick: (event: TimelineItem.Event) -> Boolean,
     onUserDataClick: (UserId) -> Unit,
     onLinkClick: (String) -> Unit,
     onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
@@ -142,9 +142,9 @@ fun MessagesView(
     // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
     val localView = LocalView.current

-    fun onMessageClick(event: TimelineItem.Event) {
+    fun onContentClick(event: TimelineItem.Event) {
         Timber.v("onMessageClick= ${event.id}")
-        val hideKeyboard = onEventClick(event)
+        val hideKeyboard = onEventContentClick(event)
         if (hideKeyboard) {
             localView.hideKeyboard()
         }
@@ -206,7 +206,7 @@ fun MessagesView(
                 modifier = Modifier
                     .padding(padding)
                     .consumeWindowInsets(padding),
-                onMessageClick = ::onMessageClick,
+                onContentClick = ::onContentClick,
                 onMessageLongClick = ::onMessageLongClick,
                 onUserDataClick = onUserDataClick,
                 onLinkClick = onLinkClick,
@@ -306,7 +306,7 @@ private fun AttachmentStateView(
 @Composable
 private fun MessagesViewContent(
     state: MessagesState,
-    onMessageClick: (TimelineItem.Event) -> Unit,
+    onContentClick: (TimelineItem.Event) -> Unit,
     onUserDataClick: (UserId) -> Unit,
     onLinkClick: (String) -> Unit,
     onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -382,7 +382,7 @@ private fun MessagesViewContent(
                         timelineProtectionState = state.timelineProtectionState,
                         onUserDataClick = onUserDataClick,
                         onLinkClick = onLinkClick,
-                        onMessageClick = onMessageClick,
+                        onContentClick = onContentClick,
                         onMessageLongClick = onMessageLongClick,
                         onSwipeToReply = onSwipeToReply,
                         onReactionClick = onReactionClick,
@@ -568,7 +568,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
         state = state,
         onBackClick = {},
         onRoomDetailsClick = {},
-        onEventClick = { false },
+        onEventContentClick = { false },
         onUserDataClick = {},
         onLinkClick = {},
         onPreviewAttachments = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
index c34f072c6d0..04750b6ad86 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
@@ -33,7 +33,7 @@ internal fun MessagesViewWithIdentityChangePreview(
         ),
         onBackClick = {},
         onRoomDetailsClick = {},
-        onEventClick = { false },
+        onEventContentClick = { false },
         onUserDataClick = {},
         onLinkClick = {},
         onPreviewAttachments = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
index f4a3247cbab..7ae88b9647e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
@@ -216,7 +216,7 @@ private fun PinnedMessagesListLoaded(
                 focusedEventId = null,
                 onUserDataClick = onUserDataClick,
                 onLinkClick = onLinkClick,
-                onClick = onEventClick,
+                onContentClick = onEventClick,
                 onLongClick = ::onMessageLongClick,
                 inReplyToClick = {},
                 onReactionClick = { _, _ -> },
@@ -230,6 +230,7 @@ private fun PinnedMessagesListLoaded(
                     TimelineItemEventContentViewWrapper(
                         event = event,
                         timelineProtectionState = state.timelineProtectionState,
+                        onContentClick = { onEventClick(event) },
                         onLinkClick = onLinkClick,
                         modifier = contentModifier,
                         onContentLayoutChange = onContentLayoutChange
@@ -244,6 +245,7 @@ private fun PinnedMessagesListLoaded(
 private fun TimelineItemEventContentViewWrapper(
     event: TimelineItem.Event,
     timelineProtectionState: TimelineProtectionState,
+    onContentClick: () -> Unit,
     onLinkClick: (String) -> Unit,
     onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
     modifier: Modifier = Modifier,
@@ -258,10 +260,11 @@ private fun TimelineItemEventContentViewWrapper(
         TimelineItemEventContentView(
             content = event.content,
             hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
-            onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+            onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
             onLinkClick = onLinkClick,
             eventSink = { },
             modifier = modifier,
+            onContentClick = onContentClick,
             onContentLayoutChange = onContentLayoutChange
         )
     }
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 f0e976e3685..6e34bba9e1a 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
@@ -76,7 +76,7 @@ fun TimelineView(
     timelineProtectionState: TimelineProtectionState,
     onUserDataClick: (UserId) -> Unit,
     onLinkClick: (String) -> Unit,
-    onMessageClick: (TimelineItem.Event) -> Unit,
+    onContentClick: (TimelineItem.Event) -> Unit,
     onMessageLongClick: (TimelineItem.Event) -> Unit,
     onSwipeToReply: (TimelineItem.Event) -> Unit,
     onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@@ -141,7 +141,7 @@ fun TimelineView(
                         focusedEventId = state.focusedEventId,
                         onUserDataClick = onUserDataClick,
                         onLinkClick = onLinkClick,
-                        onClick = onMessageClick,
+                        onContentClick = onContentClick,
                         onLongClick = onMessageLongClick,
                         inReplyToClick = ::inReplyToClick,
                         onReactionClick = onReactionClick,
@@ -322,7 +322,7 @@ internal fun TimelineViewPreview(
             timelineProtectionState = aTimelineProtectionState(),
             onUserDataClick = {},
             onLinkClick = {},
-            onMessageClick = {},
+            onContentClick = {},
             onMessageLongClick = {},
             onSwipeToReply = {},
             onReactionClick = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt
index 99f69675fa1..462e74137af 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt
@@ -41,7 +41,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
             timelineProtectionState = aTimelineProtectionState(),
             onUserDataClick = {},
             onLinkClick = {},
-            onMessageClick = {},
+            onContentClick = {},
             onMessageLongClick = {},
             onSwipeToReply = {},
             onReactionClick = { _, _ -> },
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 1fa5e7f9a1b..85e98549194 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
@@ -30,7 +30,7 @@ internal fun ATimelineItemEventRow(
     timelineProtectionState = timelineProtectionState,
     isLastOutgoingMessage = isLastOutgoingMessage,
     isHighlighted = isHighlighted,
-    onClick = {},
+    onContentClick = {},
     onLongClick = {},
     onLinkClick = {},
     onUserDataClick = {},
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 2f0165cd7b2..0061ebe6dff 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
@@ -114,7 +114,7 @@ fun TimelineItemEventRow(
     renderReadReceipts: Boolean,
     isLastOutgoingMessage: Boolean,
     isHighlighted: Boolean,
-    onClick: () -> Unit,
+    onContentClick: () -> Unit,
     onLongClick: () -> Unit,
     onLinkClick: (String) -> Unit,
     onUserDataClick: (UserId) -> Unit,
@@ -130,7 +130,8 @@ fun TimelineItemEventRow(
         TimelineItemEventContentView(
             content = event.content,
             hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
-            onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+            onContentClick = onContentClick,
+            onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
             onLinkClick = onLinkClick,
             eventSink = eventSink,
             modifier = contentModifier,
@@ -150,6 +151,12 @@ fun TimelineItemEventRow(
         inReplyToClick(inReplyToEventId)
     }

+    val onWholeItemClick = if (event.isWholeContentClickable) {
+        onContentClick
+    } else {
+        {}
+    }
+
     Column(modifier = modifier.fillMaxWidth()) {
         if (event.groupPosition.isNew()) {
             Spacer(modifier = Modifier.height(16.dp))
@@ -173,7 +180,7 @@ fun TimelineItemEventRow(
                         isHighlighted = isHighlighted,
                         timelineRoomInfo = timelineRoomInfo,
                         interactionSource = interactionSource,
-                        onClick = onClick,
+                        onContentClick = onWholeItemClick,
                         onLongClick = onLongClick,
                         inReplyToClick = ::inReplyToClick,
                         onUserDataClick = ::onUserDataClick,
@@ -207,7 +214,7 @@ fun TimelineItemEventRow(
                 isHighlighted = isHighlighted,
                 timelineRoomInfo = timelineRoomInfo,
                 interactionSource = interactionSource,
-                onClick = onClick,
+                onContentClick = onWholeItemClick,
                 onLongClick = onLongClick,
                 inReplyToClick = ::inReplyToClick,
                 onUserDataClick = ::onUserDataClick,
@@ -263,7 +270,7 @@ private fun TimelineItemEventRowContent(
     isHighlighted: Boolean,
     timelineRoomInfo: TimelineRoomInfo,
     interactionSource: MutableInteractionSource,
-    onClick: () -> Unit,
+    onContentClick: () -> Unit,
     onLongClick: () -> Unit,
     inReplyToClick: () -> Unit,
     onUserDataClick: () -> Unit,
@@ -340,7 +347,7 @@ private fun TimelineItemEventRowContent(
                 },
             state = bubbleState,
             interactionSource = interactionSource,
-            onClick = onClick,
+            onClick = onContentClick,
             onLongClick = onLongClick,
         ) {
             MessageEventBubbleContent(
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 d280efa62c8..fe89d6de022 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
@@ -57,10 +57,11 @@ fun TimelineItemGroupedEventsRow(
             TimelineItemEventContentView(
                 content = event.content,
                 hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
-                onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+                onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
                 onLinkClick = onLinkClick,
                 eventSink = eventSink,
                 modifier = contentModifier,
+                onContentClick = {},
                 onContentLayoutChange = onContentLayoutChange
             )
         },
@@ -121,10 +122,11 @@ private fun TimelineItemGroupedEventsRowContent(
             TimelineItemEventContentView(
                 content = event.content,
                 hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
-                onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+                onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
                 onLinkClick = onLinkClick,
                 eventSink = eventSink,
                 modifier = contentModifier,
+                onContentClick = {},
                 onContentLayoutChange = onContentLayoutChange
             )
         },
@@ -152,7 +154,7 @@ private fun TimelineItemGroupedEventsRowContent(
                         focusedEventId = focusedEventId,
                         onUserDataClick = onUserDataClick,
                         onLinkClick = onLinkClick,
-                        onClick = onClick,
+                        onContentClick = onClick,
                         onLongClick = onLongClick,
                         inReplyToClick = inReplyToClick,
                         onReactionClick = onReactionClick,
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 13c247645bc..047f9b3d04a 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
@@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
 import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
 import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
-import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
 import io.element.android.libraries.designsystem.text.toPx
 import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
 import io.element.android.libraries.matrix.api.core.EventId
@@ -44,7 +43,7 @@ internal fun TimelineItemRow(
     focusedEventId: EventId?,
     onUserDataClick: (UserId) -> Unit,
     onLinkClick: (String) -> Unit,
-    onClick: (TimelineItem.Event) -> Unit,
+    onContentClick: (TimelineItem.Event) -> Unit,
     onLongClick: (TimelineItem.Event) -> Unit,
     inReplyToClick: (EventId) -> Unit,
     onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -60,7 +59,8 @@ internal fun TimelineItemRow(
             TimelineItemEventContentView(
                 content = event.content,
                 hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
-                onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+                onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
+                onContentClick = { onContentClick(event) },
                 onLinkClick = onLinkClick,
                 eventSink = eventSink,
                 modifier = contentModifier,
@@ -95,7 +95,7 @@ internal fun TimelineItemRow(
                             renderReadReceipts = renderReadReceipts,
                             isLastOutgoingMessage = isLastOutgoingMessage,
                             isHighlighted = timelineItem.isEvent(focusedEventId),
-                            onClick = { onClick(timelineItem) },
+                            onClick = { onContentClick(timelineItem) },
                             onReadReceiptsClick = onReadReceiptClick,
                             onLongClick = { onLongClick(timelineItem) },
                             eventSink = eventSink,
@@ -118,11 +118,7 @@ internal fun TimelineItemRow(
                             timelineProtectionState = timelineProtectionState,
                             isLastOutgoingMessage = isLastOutgoingMessage,
                             isHighlighted = timelineItem.isEvent(focusedEventId),
-                            onClick = if (timelineProtectionState.hideMediaContent(timelineItem.eventId) && timelineItem.mustBeProtected()) {
-                                {}
-                            } else {
-                                { onClick(timelineItem) }
-                            },
+                            onContentClick = { onContentClick(timelineItem) },
                             onLongClick = { onLongClick(timelineItem) },
                             onLinkClick = onLinkClick,
                             onUserDataClick = onUserDataClick,
@@ -148,7 +144,7 @@ internal fun TimelineItemRow(
                     renderReadReceipts = renderReadReceipts,
                     isLastOutgoingMessage = isLastOutgoingMessage,
                     focusedEventId = focusedEventId,
-                    onClick = onClick,
+                    onClick = onContentClick,
                     onLongClick = onLongClick,
                     inReplyToClick = inReplyToClick,
                     onUserDataClick = onUserDataClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt
index f023a63c359..61726860160 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt
@@ -72,8 +72,9 @@ fun TimelineItemStateEventRow(
                     content = event.content,
                     onLinkClick = {},
                     hideMediaContent = false,
-                    onShowClick = {},
+                    onShowContentClick = {},
                     eventSink = eventSink,
+                    onContentClick = {},
                     modifier = Modifier.defaultTimelineContentPadding()
                 )
             }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
index b24a5ca0be5..3a3a43a6fc2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
@@ -36,7 +36,8 @@ import io.element.android.libraries.architecture.Presenter
 fun TimelineItemEventContentView(
     content: TimelineItemEventContent,
     hideMediaContent: Boolean,
-    onShowClick: () -> Unit,
+    onContentClick: () -> Unit,
+    onShowContentClick: () -> Unit,
     onLinkClick: (url: String) -> Unit,
     eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
     modifier: Modifier = Modifier,
@@ -67,25 +68,31 @@ fun TimelineItemEventContentView(
         )
         is TimelineItemLocationContent -> TimelineItemLocationView(
             content = content,
+            onContentClick = onContentClick,
             modifier = modifier
         )
         is TimelineItemImageContent -> TimelineItemImageView(
             content = content,
             hideMediaContent = hideMediaContent,
-            onShowClick = onShowClick,
+            onContentClick = onContentClick,
+            onShowContentClick = onShowContentClick,
+            onLinkClick = onLinkClick,
             onContentLayoutChange = onContentLayoutChange,
             modifier = modifier,
         )
         is TimelineItemStickerContent -> TimelineItemStickerView(
             content = content,
             hideMediaContent = hideMediaContent,
-            onShowClick = onShowClick,
+            onContentClick = onContentClick,
+            onShowClick = onShowContentClick,
             modifier = modifier,
         )
         is TimelineItemVideoContent -> TimelineItemVideoView(
             content = content,
             hideMediaContent = hideMediaContent,
-            onShowClick = onShowClick,
+            onContentClick = onContentClick,
+            onShowContentClick = onShowContentClick,
+            onLinkClick = onLinkClick,
             onContentLayoutChange = onContentLayoutChange,
             modifier = modifier
         )
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
index 63bbac7f6f8..123ba195f1e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event

 import android.text.SpannedString
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -50,7 +51,6 @@ import io.element.android.features.messages.impl.timeline.protection.ProtectedVi
 import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
 import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
 import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.wysiwyg.compose.EditorStyledText
@@ -59,7 +59,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
 fun TimelineItemImageView(
     content: TimelineItemImageContent,
     hideMediaContent: Boolean,
-    onShowClick: () -> Unit,
+    onContentClick: () -> Unit,
+    onLinkClick: (String) -> Unit,
+    onShowContentClick: () -> Unit,
     onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -78,13 +80,14 @@ fun TimelineItemImageView(
         ) {
             ProtectedView(
                 hideContent = hideMediaContent,
-                onShowClick = onShowClick,
+                onShowClick = onShowContentClick,
             ) {
                 var isLoaded by remember { mutableStateOf(false) }
                 AsyncImage(
                     modifier = Modifier
                         .fillMaxWidth()
-                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
+                        .clickable(onClick = onContentClick),
                     model = content.thumbnailMediaRequestData,
                     contentScale = ContentScale.Fit,
                     alignment = Alignment.Center,
@@ -99,9 +102,7 @@ fun TimelineItemImageView(
             val caption = if (LocalInspectionMode.current) {
                 SpannedString(content.caption)
             } else {
-                content.formattedCaption?.body
-                    ?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
-                    ?: SpannedString(content.caption)
+                content.formattedCaption ?: SpannedString(content.caption)
             }
             CompositionLocalProvider(
                 LocalContentColor provides ElementTheme.colors.textPrimary,
@@ -114,6 +115,7 @@ fun TimelineItemImageView(
                         .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
                     text = caption,
                     style = ElementRichTextEditorStyle.textStyle(),
+                    onLinkClickedListener = onLinkClick,
                     releaseOnDetach = false,
                     onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
                 )
@@ -128,7 +130,9 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
     TimelineItemImageView(
         content = content,
         hideMediaContent = false,
-        onShowClick = {},
+        onShowContentClick = {},
+        onContentClick = {},
+        onLinkClick = {},
         onContentLayoutChange = {},
     )
 }
@@ -139,7 +143,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
     TimelineItemImageView(
         content = aTimelineItemImageContent(),
         hideMediaContent = true,
-        onShowClick = {},
+        onShowContentClick = {},
+        onContentClick = {},
+        onLinkClick = {},
         onContentLayoutChange = {},
     )
 }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
index 91cfcff1d8f..5d402bc8f16 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
@@ -7,6 +7,7 @@

 package io.element.android.features.messages.impl.timeline.components.event

+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.heightIn
@@ -25,9 +26,10 @@ import io.element.android.libraries.designsystem.theme.components.Text
 @Composable
 fun TimelineItemLocationView(
     content: TimelineItemLocationContent,
+    onContentClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
-    Column(modifier = modifier.fillMaxWidth()) {
+    Column(modifier = modifier.clickable(onClick = onContentClick).fillMaxWidth()) {
         content.description?.let {
             Text(
                 text = it,
@@ -51,5 +53,8 @@ fun TimelineItemLocationView(
 @Composable
 internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
     ElementPreview {
-        TimelineItemLocationView(content)
+        TimelineItemLocationView(
+            content = content,
+            onContentClick = {},
+        )
     }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
index 0574843dbaf..df087769ed8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
@@ -8,6 +8,7 @@
 package io.element.android.features.messages.impl.timeline.components.event

 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
@@ -40,6 +41,7 @@ private const val STICKER_SIZE_IN_DP = 128
 fun TimelineItemStickerView(
     content: TimelineItemStickerContent,
     hideMediaContent: Boolean,
+    onContentClick: () -> Unit,
     onShowClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -61,7 +63,8 @@ fun TimelineItemStickerView(
                 AsyncImage(
                     modifier = Modifier
                         .fillMaxSize()
-                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
+                        .clickable(onClick = onContentClick),
                     model = MediaRequestData(
                         source = content.preferredMediaSource,
                         kind = MediaRequestData.Kind.File(
@@ -85,6 +88,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
     TimelineItemStickerView(
         content = content,
         hideMediaContent = false,
+        onContentClick = {},
         onShowClick = {},
     )
 }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
index 64e6d00d718..65f59cd631f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components.event
 import android.text.SpannedString
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
@@ -56,7 +57,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
 import io.element.android.libraries.designsystem.modifiers.roundedBackground
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
 import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
 import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
 import io.element.android.libraries.matrix.ui.media.MediaRequestData
@@ -68,7 +68,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
 fun TimelineItemVideoView(
     content: TimelineItemVideoContent,
     hideMediaContent: Boolean,
-    onShowClick: () -> Unit,
+    onContentClick: () -> Unit,
+    onShowContentClick: () -> Unit,
+    onLinkClick: (String) -> Unit,
     onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -90,13 +92,14 @@ fun TimelineItemVideoView(
         ) {
             ProtectedView(
                 hideContent = hideMediaContent,
-                onShowClick = onShowClick,
+                onShowClick = onShowContentClick,
             ) {
                 var isLoaded by remember { mutableStateOf(false) }
                 AsyncImage(
                     modifier = Modifier
                         .fillMaxWidth()
-                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
+                        .clickable(onClick = onContentClick),
                     model = MediaRequestData(
                         source = content.thumbnailSource,
                         kind = MediaRequestData.Kind.Thumbnail(
@@ -128,9 +131,7 @@ fun TimelineItemVideoView(
             val caption = if (LocalInspectionMode.current) {
                 SpannedString(content.caption)
             } else {
-                content.formattedCaption?.body
-                    ?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
-                    ?: SpannedString(content.caption)
+                content.formattedCaption ?: SpannedString(content.caption)
             }
             CompositionLocalProvider(
                 LocalContentColor provides ElementTheme.colors.textPrimary,
@@ -142,6 +143,7 @@ fun TimelineItemVideoView(
                         .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
                         .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
                     text = caption,
+                    onLinkClickedListener = onLinkClick,
                     style = ElementRichTextEditorStyle.textStyle(),
                     releaseOnDetach = false,
                     onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
@@ -157,7 +159,9 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
     TimelineItemVideoView(
         content = content,
         hideMediaContent = false,
-        onShowClick = {},
+        onShowContentClick = {},
+        onContentClick = {},
+        onLinkClick = {},
         onContentLayoutChange = {},
     )
 }
@@ -168,7 +172,9 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
     TimelineItemVideoView(
         content = aTimelineItemVideoContent(),
         hideMediaContent = true,
-        onShowClick = {},
+        onShowContentClick = {},
+        onContentClick = {},
+        onLinkClick = {},
         onContentLayoutChange = {},
     )
 }
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 001d40b254f..ff5ca57c113 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
@@ -86,7 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 TimelineItemImageContent(
                     filename = messageType.filename,
                     caption = messageType.caption?.trimEnd(),
-                    formattedCaption = messageType.formattedCaption,
+                    formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                     mediaSource = messageType.source,
                     thumbnailSource = messageType.info?.thumbnailSource,
                     mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -105,7 +105,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 TimelineItemStickerContent(
                     filename = messageType.filename,
                     caption = messageType.caption?.trimEnd(),
-                    formattedCaption = messageType.formattedCaption,
+                    formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                     mediaSource = messageType.source,
                     thumbnailSource = messageType.info?.thumbnailSource,
                     mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -142,7 +142,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 TimelineItemVideoContent(
                     filename = messageType.filename,
                     caption = messageType.caption?.trimEnd(),
-                    formattedCaption = messageType.formattedCaption,
+                    formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                     thumbnailSource = messageType.info?.thumbnailSource,
                     videoSource = messageType.source,
                     mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -161,7 +161,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 TimelineItemAudioContent(
                     filename = messageType.filename,
                     caption = messageType.caption?.trimEnd(),
-                    formattedCaption = messageType.formattedCaption,
+                    formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                     mediaSource = messageType.source,
                     duration = messageType.info?.duration ?: Duration.ZERO,
                     mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -176,7 +176,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                             eventId = eventId,
                             filename = messageType.filename,
                             caption = messageType.caption?.trimEnd(),
-                            formattedCaption = messageType.formattedCaption,
+                            formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                             mediaSource = messageType.source,
                             duration = messageType.info?.duration ?: Duration.ZERO,
                             mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -187,7 +187,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                         TimelineItemAudioContent(
                             filename = messageType.filename,
                             caption = messageType.caption?.trimEnd(),
-                            formattedCaption = messageType.formattedCaption,
+                            formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                             mediaSource = messageType.source,
                             duration = messageType.info?.duration ?: Duration.ZERO,
                             mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -202,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
                 TimelineItemFileContent(
                     filename = messageType.filename,
                     caption = messageType.caption?.trimEnd(),
-                    formattedCaption = messageType.formattedCaption,
+                    formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
                     thumbnailSource = messageType.info?.thumbnailSource,
                     fileSource = messageType.source,
                     mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
index dcf0e16aa83..e2c81a3794e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
@@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.model

 import androidx.compose.runtime.Immutable
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
 import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
 import io.element.android.libraries.designsystem.components.avatar.AvatarData
 import io.element.android.libraries.matrix.api.core.EventId
@@ -93,6 +95,17 @@ sealed interface TimelineItem {

         val isRemote = eventId != null

+        /** Whether a click on any part of the event bubble should trigger the 'onContentClick' callback.
+         *
+         *  This is `true` for all events except for visual media events with a caption or formatted caption.
+         */
+        val isWholeContentClickable = when (content) {
+            is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
+            is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
+            is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
+            else -> true
+        }
+
         val eventOrTransactionId: EventOrTransactionId
             get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
index fa2957b8604..0ecf45ec55b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
@@ -8,14 +8,13 @@
 package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
 import kotlin.time.Duration

 data class TimelineItemAudioContent(
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val duration: Duration,
     val mediaSource: MediaSource,
     val mimeType: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index 10204a75d8b..fafadcbf79d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -8,7 +8,6 @@
 package io.element.android.features.messages.impl.timeline.model.event

 import androidx.compose.runtime.Immutable
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody

 @Immutable
 sealed interface TimelineItemEventContent {
@@ -19,7 +18,7 @@ sealed interface TimelineItemEventContent {
 sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
     val filename: String
     val caption: String?
-    val formattedCaption: FormattedBody?
+    val formattedCaption: CharSequence?

     val bestDescription: String
         get() = caption ?: filename
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
index f11325727cc..b7007c8bbca 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
@@ -8,13 +8,12 @@
 package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize

 data class TimelineItemFileContent(
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val fileSource: MediaSource,
     val thumbnailSource: MediaSource?,
     val formattedFileSize: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
index e6e4bffb9b2..b13c7a4e377 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
@@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
 import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
 import io.element.android.libraries.matrix.ui.media.MediaRequestData
@@ -17,7 +16,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
 data class TimelineItemImageContent(
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val mediaSource: MediaSource,
     val thumbnailSource: MediaSource?,
     val formattedFileSize: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
index 3476d97e2fc..06886307ae6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
@@ -8,12 +8,11 @@
 package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody

 data class TimelineItemStickerContent(
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val mediaSource: MediaSource,
     val thumbnailSource: MediaSource?,
     val formattedFileSize: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
index 5c0e601708d..486a71b5d43 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
@@ -8,13 +8,12 @@
 package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import kotlin.time.Duration

 data class TimelineItemVideoContent(
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val duration: Duration,
     val videoSource: MediaSource,
     val thumbnailSource: MediaSource?,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
index 853cc01045f..ebeabef7150 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
@@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event

 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
 import kotlinx.collections.immutable.ImmutableList
 import kotlin.time.Duration

@@ -17,7 +16,7 @@ data class TimelineItemVoiceContent(
     val eventId: EventId?,
     override val filename: String,
     override val caption: String?,
-    override val formattedCaption: FormattedBody?,
+    override val formattedCaption: CharSequence?,
     val duration: Duration,
     val mediaSource: MediaSource,
     val mimeType: String,

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 a20acaa5df0..c040ce99264 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
@@ -529,7 +529,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
                 state = state,
                 onBackClick = onBackClick,
                 onRoomDetailsClick = onRoomDetailsClick,
-                onEventClick = onEventClick,
+                onEventContentClick = onEventClick,
                 onUserDataClick = onUserDataClick,
                 onLinkClick = onLinkClick,
                 onPreviewAttachments = onPreviewAttachments,
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 315abd83020..6a7b49c34c6 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
@@ -158,7 +158,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
             timelineProtectionState = timelineProtectionState,
             onUserDataClick = onUserDataClick,
             onLinkClick = onLinkClick,
-            onMessageClick = onMessageClick,
+            onContentClick = onMessageClick,
             onMessageLongClick = onMessageLongClick,
             onSwipeToReply = onSwipeToReply,
             onReactionClick = onReactionClick,
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 e9343bf21d0..a34e601e5da 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
@@ -286,7 +286,7 @@ class TimelineItemContentMessageFactoryTest {
         val expected = TimelineItemVideoContent(
             filename = "body.mp4",
             caption = "body.mp4 caption",
-            formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
+            formattedCaption = SpannedString("formatted"),
             duration = 1.minutes,
             videoSource = MediaSource(url = "url", json = null),
             thumbnailSource = MediaSource("url_thumbnail"),
@@ -527,7 +527,7 @@ class TimelineItemContentMessageFactoryTest {
         )
         val expected = TimelineItemImageContent(
             filename = "body.jpg",
-            formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
+            formattedCaption = SpannedString("formatted"),
             caption = "body.jpg caption",
             mediaSource = MediaSource(url = "url", json = null),
             thumbnailSource = MediaSource("url_thumbnail"),