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

Issue:
Remove `AttachmentsState` and use the `MessagesNavigator`
To do once https://github.com/element-hq/element-x-android/pull/3902 has been merged.


Discussion hints:

Pull request: Remove AttachmentsState and use the MessagesNavigator
Remove AttachmentsState and use the MessagesNavigator.

Close #3910 

Let the Node use the factories so that we have cleaner Presenter constructors.

Test command: ./gradlew :features:messages:impl:testDebugUnitTest --tests "io.element.android.features.messages.impl.timeline.TimelinePresenterTest" --tests "io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenterTest" --tests "io.element.android.features.messages.impl.MessagesViewTest" --tests "io.element.android.features.messages.impl.MessagesPresenterTest"

Fix patch:
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
index 3eba997eea9..470d9da9c42 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
@@ -7,13 +7,16 @@
 
 package io.element.android.features.messages.impl
 
+import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+import kotlinx.collections.immutable.ImmutableList
 
 interface MessagesNavigator {
     fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
     fun onForwardEventClick(eventId: EventId)
     fun onReportContentClick(eventId: EventId, senderId: UserId)
     fun onEditPollClick(eventId: EventId)
+    fun onPreviewAttachment(attachments: ImmutableList<Attachment>)
 }
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 9fd823445f8..17985848537 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
@@ -30,7 +30,9 @@ import io.element.android.anvilannotations.ContributesNode
 import io.element.android.compound.theme.ElementTheme
 import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
+import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
 import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.TimelinePresenter
 import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
 import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
 import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -60,12 +62,18 @@ class MessagesNode @AssistedInject constructor(
     @Assisted plugins: List<Plugin>,
     private val room: MatrixRoom,
     private val analyticsService: AnalyticsService,
+    messageComposerPresenterFactory: MessageComposerPresenter.Factory,
+    timelinePresenterFactory: TimelinePresenter.Factory,
     presenterFactory: MessagesPresenter.Factory,
     private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
     private val mediaPlayer: MediaPlayer,
     private val permalinkParser: PermalinkParser,
 ) : Node(buildContext, plugins = plugins), MessagesNavigator {
-    private val presenter = presenterFactory.create(this)
+    private val presenter = presenterFactory.create(
+        navigator = this,
+        composerPresenter = messageComposerPresenterFactory.create(this),
+        timelinePresenter = timelinePresenterFactory.create(this),
+    )
     private val callbacks = plugins<Callback>()
 
     data class Inputs(val focusedEventId: EventId?) : NodeInputs
@@ -114,10 +122,6 @@ class MessagesNode @AssistedInject constructor(
             .orFalse()
     }
 
-    private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
-        callbacks.forEach { it.onPreviewAttachments(attachments) }
-    }
-
     private fun onUserDataClick(userId: UserId) {
         callbacks.forEach { it.onUserDataClick(userId) }
     }
@@ -178,6 +182,10 @@ class MessagesNode @AssistedInject constructor(
         callbacks.forEach { it.onEditPollClick(eventId) }
     }
 
+    override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
+        callbacks.forEach { it.onPreviewAttachments(attachments) }
+    }
+
     private fun onViewAllPinnedMessagesClick() {
         callbacks.forEach { it.onViewAllPinnedEvents() }
     }
@@ -213,7 +221,6 @@ class MessagesNode @AssistedInject constructor(
                 onBackClick = this::navigateUp,
                 onRoomDetailsClick = this::onRoomDetailsClick,
                 onEventContentClick = this::onEventClick,
-                onPreviewAttachments = this::onPreviewAttachments,
                 onUserDataClick = this::onUserDataClick,
                 onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
                 onSendLocationClick = this::onSendLocationClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 49d3015c056..e39056ec9b7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -37,7 +37,6 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
 import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
 import io.element.android.features.messages.impl.timeline.TimelineController
 import io.element.android.features.messages.impl.timeline.TimelineEvents
-import io.element.android.features.messages.impl.timeline.TimelinePresenter
 import io.element.android.features.messages.impl.timeline.TimelineState
 import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
 import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@@ -89,12 +88,12 @@ import timber.log.Timber
 class MessagesPresenter @AssistedInject constructor(
     @Assisted private val navigator: MessagesNavigator,
     private val room: MatrixRoom,
-    private val composerPresenter: Presenter<MessageComposerState>,
+    @Assisted private val composerPresenter: Presenter<MessageComposerState>,
     private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
-    timelinePresenterFactory: TimelinePresenter.Factory,
+    @Assisted private val timelinePresenter: Presenter<TimelineState>,
     private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
     private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
-    private val actionListPresenterFactory: ActionListPresenter.Factory,
+    actionListPresenterFactory: ActionListPresenter.Factory,
     private val customReactionPresenter: Presenter<CustomReactionState>,
     private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
     private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
@@ -111,12 +110,15 @@ class MessagesPresenter @AssistedInject constructor(
     private val permalinkParser: PermalinkParser,
     private val analyticsService: AnalyticsService,
 ) : Presenter<MessagesState> {
-    private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
     private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
 
     @AssistedFactory
     interface Factory {
-        fun create(navigator: MessagesNavigator): MessagesPresenter
+        fun create(
+            navigator: MessagesNavigator,
+            composerPresenter: Presenter<MessageComposerState>,
+            timelinePresenter: Presenter<TimelineState>,
+        ): MessagesPresenter
     }
 
     @Composable
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 49cbac6268b..fa66e463bbc 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
@@ -32,10 +32,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -55,10 +53,8 @@ import io.element.android.compound.theme.ElementTheme
 import io.element.android.features.messages.impl.actionlist.ActionListEvents
 import io.element.android.features.messages.impl.actionlist.ActionListView
 import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
-import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
 import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
-import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
 import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
 import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
 import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
@@ -115,7 +111,6 @@ fun MessagesView(
     onEventContentClick: (event: TimelineItem.Event) -> Boolean,
     onUserDataClick: (UserId) -> Unit,
     onLinkClick: (String) -> Unit,
-    onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
     onSendLocationClick: () -> Unit,
     onCreatePollClick: () -> Unit,
     onJoinCallClick: () -> Unit,
@@ -129,11 +124,6 @@ fun MessagesView(
 
     KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
 
-    AttachmentStateView(
-        state = state.composerState.attachmentsState,
-        onPreviewAttachments = onPreviewAttachments,
-    )
-
     val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
 
     // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
@@ -273,22 +263,6 @@ private fun ReinviteDialog(state: MessagesState) {
     }
 }
 
-@Composable
-private fun AttachmentStateView(
-    state: AttachmentsState,
-    onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
-) {
-    when (state) {
-        AttachmentsState.None -> Unit
-        is AttachmentsState.Previewing -> {
-            val latestOnPreviewAttachments by rememberUpdatedState(onPreviewAttachments)
-            LaunchedEffect(state) {
-                latestOnPreviewAttachments(state.attachments)
-            }
-        }
-    }
-}
-
 @Composable
 private fun MessagesViewContent(
     state: MessagesState,
@@ -557,7 +531,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
         onEventContentClick = { false },
         onUserDataClick = {},
         onLinkClick = {},
-        onPreviewAttachments = {},
         onSendLocationClick = {},
         onCreatePollClick = {},
         onJoinCallClick = {},
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 04750b6ad86..34c58bdb060 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
@@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
         onEventContentClick = { false },
         onUserDataClick = {},
         onLinkClick = {},
-        onPreviewAttachments = {},
         onSendLocationClick = {},
         onCreatePollClick = {},
         onJoinCallClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
index d987e978095..567c95bcc7c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
@@ -14,8 +14,6 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
 import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
 import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
 import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
 import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
 import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
 import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@@ -48,9 +46,6 @@ interface MessagesModule {
     @Binds
     fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
 
-    @Binds
-    fun bindMessageComposerPresenter(presenter: MessageComposerPresenter): Presenter<MessageComposerState>
-
     @Binds
     fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
 
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index ebccc8dff46..bf3ea2b112c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -14,7 +14,6 @@ import androidx.annotation.VisibleForTesting
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateListOf
@@ -26,8 +25,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.media3.common.MimeTypes
 import androidx.media3.common.util.UnstableApi
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
 import im.vector.app.features.analytics.plan.Composer
 import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.messages.impl.MessagesNavigator
 import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
 import io.element.android.features.messages.impl.draft.ComposerDraftService
@@ -38,8 +41,6 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
-import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.di.SingleIn
 import io.element.android.libraries.featureflag.api.FeatureFlagService
 import io.element.android.libraries.featureflag.api.FeatureFlags
 import io.element.android.libraries.matrix.api.core.UserId
@@ -89,12 +90,11 @@ import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.launch
 import timber.log.Timber
-import javax.inject.Inject
 import kotlin.time.Duration.Companion.seconds
 import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
 
-@SingleIn(RoomScope::class)
-class MessageComposerPresenter @Inject constructor(
+class MessageComposerPresenter @AssistedInject constructor(
+    @Assisted private val navigator: MessagesNavigator,
     private val appCoroutineScope: CoroutineScope,
     private val room: MatrixRoom,
     private val mediaPickerProvider: PickerProvider,
@@ -117,6 +117,11 @@ class MessageComposerPresenter @Inject constructor(
     private val roomMemberProfilesCache: RoomMemberProfilesCache,
     private val suggestionsProcessor: SuggestionsProcessor,
 ) : Presenter<MessageComposerState> {
+    @AssistedFactory
+    interface Factory {
+        fun create(navigator: MessagesNavigator): MessageComposerPresenter
+    }
+
     private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
     private var pendingEvent: MessageComposerEvents? = null
     private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
@@ -147,9 +152,6 @@ class MessageComposerPresenter @Inject constructor(
         }
 
         val cameraPermissionState = cameraPermissionPresenter.present()
-        val attachmentsState = remember {
-            mutableStateOf<AttachmentsState>(AttachmentsState.None)
-        }
 
         val canShareLocation = remember { mutableStateOf(false) }
         LaunchedEffect(Unit) {
@@ -162,16 +164,16 @@ class MessageComposerPresenter @Inject constructor(
         }
 
         val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
-            handlePickedMedia(attachmentsState, uri, mimeType)
+            handlePickedMedia(uri, mimeType)
         }
         val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
-            handlePickedMedia(attachmentsState, uri)
+            handlePickedMedia(uri)
         }
         val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
-            handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
+            handlePickedMedia(uri, MimeTypes.IMAGE_JPEG)
         }
         val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
-            handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
+            handlePickedMedia(uri, MimeTypes.VIDEO_MP4)
         }
         val isFullScreen = rememberSaveable {
             mutableStateOf(false)
@@ -277,7 +279,6 @@ class MessageComposerPresenter @Inject constructor(
                             formattedFileSize = null
                         ),
                     ),
-                    attachmentState = attachmentsState,
                 )
                 is MessageComposerEvents.SetMode -> {
                     localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@@ -396,7 +397,6 @@ class MessageComposerPresenter @Inject constructor(
             showTextFormatting = showTextFormatting,
             canShareLocation = canShareLocation.value,
             canCreatePoll = canCreatePoll.value,
-            attachmentsState = attachmentsState.value,
             suggestions = suggestions.toPersistentList(),
             resolveMentionDisplay = resolveMentionDisplay,
             eventSink = { handleEvents(it) },
@@ -459,14 +459,12 @@ class MessageComposerPresenter @Inject constructor(
 
     private fun CoroutineScope.sendAttachment(
         attachment: Attachment,
-        attachmentState: MutableState<AttachmentsState>,
     ) = when (attachment) {
         is Attachment.Media -> {
             launch {
                 sendMedia(
                     uri = attachment.localMedia.uri,
                     mimeType = attachment.localMedia.info.mimeType,
-                    attachmentState = attachmentState,
                 )
             }
         }
@@ -474,14 +472,10 @@ class MessageComposerPresenter @Inject constructor(
 
     @UnstableApi
     private fun handlePickedMedia(
-        attachmentsState: MutableState<AttachmentsState>,
         uri: Uri?,
         mimeType: String? = null,
     ) {
-        if (uri == null) {
-            attachmentsState.value = AttachmentsState.None
-            return
-        }
+        uri ?: return
         val localMedia = localMediaFactory.createFromUri(
             uri = uri,
             mimeType = mimeType,
@@ -489,13 +483,12 @@ class MessageComposerPresenter @Inject constructor(
             formattedFileSize = null
         )
         val mediaAttachment = Attachment.Media(localMedia)
-        attachmentsState.value = AttachmentsState.Previewing(persistentListOf(mediaAttachment))
+        navigator.onPreviewAttachment(persistentListOf(mediaAttachment))
     }
 
     private suspend fun sendMedia(
         uri: Uri,
         mimeType: String,
-        attachmentState: MutableState<AttachmentsState>,
     ) = runCatching {
         mediaSender.sendMedia(
             uri = uri,
@@ -503,12 +496,8 @@ class MessageComposerPresenter @Inject constructor(
             progressCallback = null,
         ).getOrThrow()
     }
-        .onSuccess {
-            attachmentState.value = AttachmentsState.None
-        }
         .onFailure { cause ->
             Timber.e(cause, "Failed to send attachment")
-            attachmentState.value = AttachmentsState.None
             if (cause is CancellationException) {
                 throw cause
             } else {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index d0e9528e487..58e40dbb14e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -7,9 +7,7 @@
 
 package io.element.android.features.messages.impl.messagecomposer
 
-import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
-import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -25,14 +23,7 @@ data class MessageComposerState(
     val showTextFormatting: Boolean,
     val canShareLocation: Boolean,
     val canCreatePoll: Boolean,
-    val attachmentsState: AttachmentsState,
     val suggestions: ImmutableList<ResolvedSuggestion>,
     val resolveMentionDisplay: (String, String) -> TextDisplay,
     val eventSink: (MessageComposerEvents) -> Unit,
 )
-
-@Immutable
-sealed interface AttachmentsState {
-    data object None : AttachmentsState
-    data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
-}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index a36102bc7d8..7811322c3dd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -31,7 +31,6 @@ fun aMessageComposerState(
     showAttachmentSourcePicker: Boolean = false,
     canShareLocation: Boolean = true,
     canCreatePoll: Boolean = true,
-    attachmentsState: AttachmentsState = AttachmentsState.None,
     suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
     eventSink: (MessageComposerEvents) -> Unit = {},
 ) = MessageComposerState(
@@ -42,7 +41,6 @@ fun aMessageComposerState(
     showAttachmentSourcePicker = showAttachmentSourcePicker,
     canShareLocation = canShareLocation,
     canCreatePoll = canCreatePoll,
-    attachmentsState = attachmentsState,
     suggestions = suggestions,
     resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
     eventSink = eventSink,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
index cf8d5668a3e..cc6d134802e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
@@ -23,8 +23,6 @@ import im.vector.app.features.analytics.plan.Composer
 import io.element.android.features.messages.api.MessageComposerContext
 import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
 import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.di.SingleIn
 import io.element.android.libraries.mediaupload.api.MediaSender
 import io.element.android.libraries.permissions.api.PermissionsEvents
 import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -45,7 +43,6 @@ import javax.inject.Inject
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 
-@SingleIn(RoomScope::class)
 class VoiceMessageComposerPresenter @Inject constructor(
     private val appCoroutineScope: CoroutineScope,
     private val voiceRecorder: VoiceRecorder,


Test patch:
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
index fa5e412f27b..5dcc4e74966 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
@@ -7,36 +7,37 @@
 
 package io.element.android.features.messages.impl
 
+import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
-
-class FakeMessagesNavigator : MessagesNavigator {
-    var onShowEventDebugInfoClickedCount = 0
-        private set
-
-    var onForwardEventClickedCount = 0
-        private set
-
-    var onReportContentClickedCount = 0
-        private set
-
-    var onEditPollClickedCount = 0
-        private set
-
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.collections.immutable.ImmutableList
+
+class FakeMessagesNavigator(
+    private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
+    private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
+    private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
+    private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
+    private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() },
+) : MessagesNavigator {
     override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
-        onShowEventDebugInfoClickedCount++
+        onShowEventDebugInfoClickLambda(eventId, debugInfo)
     }
 
     override fun onForwardEventClick(eventId: EventId) {
-        onForwardEventClickedCount++
+        onForwardEventClickLambda(eventId)
     }
 
     override fun onReportContentClick(eventId: EventId, senderId: UserId) {
-        onReportContentClickedCount++
+        onReportContentClickLambda(eventId, senderId)
     }
 
     override fun onEditPollClick(eventId: EventId) {
-        onEditPollClickedCount++
+        onEditPollClickLambda(eventId)
+    }
+
+    override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
+        onPreviewAttachmentLambda(attachments)
     }
 }
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 3d4969fd28e..61606a51de9 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -23,8 +23,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
 import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
 import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
 import io.element.android.features.messages.impl.timeline.TimelineController
-import io.element.android.features.messages.impl.timeline.TimelinePresenter
-import io.element.android.features.messages.impl.timeline.createTimelinePresenter
+import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.aTimelineState
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@@ -36,8 +36,6 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
 import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
 import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
 import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
-import io.element.android.features.poll.api.actions.EndPollAction
-import io.element.android.features.poll.test.actions.FakeEndPollAction
 import io.element.android.features.roomcall.api.aStandByCallState
 import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
 import io.element.android.libraries.architecture.AsyncData
@@ -56,6 +54,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
 import io.element.android.libraries.matrix.api.room.MessageEventType
 import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
 import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
 import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
 import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@@ -65,12 +64,14 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
 import io.element.android.libraries.matrix.test.A_SESSION_ID
 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
 import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.A_USER_ID
 import io.element.android.libraries.matrix.test.core.aBuildMeta
 import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
 import io.element.android.libraries.matrix.test.room.aRoomInfo
 import io.element.android.libraries.matrix.test.room.aRoomMember
 import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
 import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -217,7 +218,10 @@ class MessagesPresenterTest {
 
     @Test
     fun `present - handle action forward`() = runTest {
-        val navigator = FakeMessagesNavigator()
+        val onForwardEventClickLambda = lambdaRecorder<EventId, Unit> { }
+        val navigator = FakeMessagesNavigator(
+            onForwardEventClickLambda = onForwardEventClickLambda,
+        )
         val presenter = createMessagesPresenter(navigator = navigator)
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
@@ -225,7 +229,7 @@ class MessagesPresenterTest {
             val initialState = awaitItem()
             initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
             assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
-            assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
+            onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
         }
     }
 
@@ -452,7 +456,10 @@ class MessagesPresenterTest {
 
     @Test
     fun `present - handle action edit poll`() = runTest {
-        val navigator = FakeMessagesNavigator()
+        val onEditPollClickLambda = lambdaRecorder<EventId, Unit> { }
+        val navigator = FakeMessagesNavigator(
+            onEditPollClickLambda = onEditPollClickLambda
+        )
         val presenter = createMessagesPresenter(navigator = navigator)
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
@@ -460,22 +467,21 @@ class MessagesPresenterTest {
             val initialState = awaitItem()
             initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
             awaitItem()
-            assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
+            onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
         }
     }
 
     @Test
     fun `present - handle action end poll`() = runTest {
-        val endPollAction = FakeEndPollAction()
-        val presenter = createMessagesPresenter(endPollAction = endPollAction)
+        val timelineEventSink = EventsRecorder<TimelineEvents>()
+        val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink)
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitItem()
-            endPollAction.verifyExecutionCount(0)
             initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
             delay(1)
-            endPollAction.verifyExecutionCount(1)
+            timelineEventSink.assertSingle(TimelineEvents.EndPoll(AN_EVENT_ID))
             cancelAndIgnoreRemainingEvents()
         }
     }
@@ -516,7 +522,10 @@ class MessagesPresenterTest {
 
     @Test
     fun `present - handle action report content`() = runTest {
-        val navigator = FakeMessagesNavigator()
+        val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> }
+        val navigator = FakeMessagesNavigator(
+            onReportContentClickLambda = onReportContentClickLambda
+        )
         val presenter = createMessagesPresenter(navigator = navigator)
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
@@ -524,7 +533,7 @@ class MessagesPresenterTest {
             val initialState = awaitItem()
             initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
             assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
-            assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
+            onReportContentClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(A_USER_ID))
         }
     }
 
@@ -542,7 +551,10 @@ class MessagesPresenterTest {
 
     @Test
     fun `present - handle action show developer info`() = runTest {
-        val navigator = FakeMessagesNavigator()
+        val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
+        val navigator = FakeMessagesNavigator(
+            onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
+        )
         val presenter = createMessagesPresenter(navigator = navigator)
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
@@ -550,7 +562,7 @@ class MessagesPresenterTest {
             val initialState = awaitItem()
             initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
             assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
-            assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
+            onShowEventDebugInfoClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(aTimelineItemDebugInfo()))
         }
     }
 
@@ -1087,7 +1099,7 @@ class MessagesPresenterTest {
         navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
         clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
         analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
-        endPollAction: EndPollAction = FakeEndPollAction(),
+        timelineEventSink: (TimelineEvents) -> Unit = {},
         permalinkParser: PermalinkParser = FakePermalinkParser(),
         messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
             aMessageComposerState(
@@ -1097,19 +1109,12 @@ class MessagesPresenterTest {
         },
         actionListEventSink: (ActionListEvents) -> Unit = {},
     ): MessagesPresenter {
-        val timelinePresenterFactory = object : TimelinePresenter.Factory {
-            override fun create(navigator: MessagesNavigator): TimelinePresenter {
-                return createTimelinePresenter(
-                    endPollAction = endPollAction,
-                )
-            }
-        }
         val featureFlagService = FakeFeatureFlagService()
         return MessagesPresenter(
             room = matrixRoom,
             composerPresenter = messageComposerPresenter,
             voiceMessageComposerPresenter = { aVoiceMessageComposerState() },
-            timelinePresenterFactory = timelinePresenterFactory,
+            timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
             timelineProtectionPresenter = { aTimelineProtectionState() },
             actionListPresenterFactory = FakeActionListPresenter.Factory(actionListEventSink),
             customReactionPresenter = { aCustomReactionState() },
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 c040ce99264..1d4e1a43b34 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
@@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
 import io.element.android.features.messages.impl.actionlist.ActionListState
 import io.element.android.features.messages.impl.actionlist.anActionListState
 import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
-import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
 import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure
 import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@@ -64,7 +63,6 @@ import io.element.android.tests.testutils.clickOn
 import io.element.android.tests.testutils.ensureCalledOnce
 import io.element.android.tests.testutils.ensureCalledOnceWithParam
 import io.element.android.tests.testutils.pressBack
-import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import org.junit.Rule
 import org.junit.Test
@@ -514,7 +512,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
     onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
     onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
     onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
-    onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
     onSendLocationClick: () -> Unit = EnsureNeverCalled(),
     onCreatePollClick: () -> Unit = EnsureNeverCalled(),
     onJoinCallClick: () -> Unit = EnsureNeverCalled(),
@@ -532,7 +529,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
                 onEventContentClick = onEventClick,
                 onUserDataClick = onUserDataClick,
                 onLinkClick = onLinkClick,
-                onPreviewAttachments = onPreviewAttachments,
                 onSendLocationClick = onSendLocationClick,
                 onCreatePollClick = onCreatePollClick,
                 onJoinCallClick = onJoinCallClick,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index 1654a061c8c..d0b41bbb1ee 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -18,6 +18,9 @@ import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
 import im.vector.app.features.analytics.plan.Composer
 import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.messages.impl.FakeMessagesNavigator
+import io.element.android.features.messages.impl.MessagesNavigator
+import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.draft.ComposerDraftService
 import io.element.android.features.messages.impl.draft.FakeComposerDraftService
 import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
@@ -91,6 +94,7 @@ import io.element.android.tests.testutils.lambda.value
 import io.element.android.tests.testutils.test
 import io.element.android.tests.testutils.waitForPredicate
 import io.mockk.mockk
+import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -133,7 +137,6 @@ class MessageComposerPresenterTest {
             assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
             assertThat(initialState.showAttachmentSourcePicker).isFalse()
             assertThat(initialState.canShareLocation).isTrue()
-            assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
         }
     }
 
@@ -685,7 +688,15 @@ class MessageComposerPresenterTest {
         val room = FakeMatrixRoom(
             typingNoticeResult = { Result.success(Unit) }
         )
-        val presenter = createPresenter(this, room = room)
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
+        val presenter = createPresenter(
+            coroutineScope = this,
+            room = room,
+            navigator = navigator,
+        )
         pickerProvider.givenMimeType(MimeTypes.Images)
         mediaPreProcessor.givenResult(
             Result.success(
@@ -709,9 +720,7 @@ class MessageComposerPresenterTest {
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
-            val previewingState = awaitItem()
-            assertThat(previewingState.showAttachmentSourcePicker).isFalse()
-            assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -720,7 +729,15 @@ class MessageComposerPresenterTest {
         val room = FakeMatrixRoom(
             typingNoticeResult = { Result.success(Unit) }
         )
-        val presenter = createPresenter(this, room = room)
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
+        val presenter = createPresenter(
+            coroutineScope = this,
+            room = room,
+            navigator = navigator,
+        )
         pickerProvider.givenMimeType(MimeTypes.Videos)
         mediaPreProcessor.givenResult(
             Result.success(
@@ -745,9 +762,7 @@ class MessageComposerPresenterTest {
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
-            val previewingState = awaitItem()
-            assertThat(previewingState.showAttachmentSourcePicker).isFalse()
-            assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -772,15 +787,21 @@ class MessageComposerPresenterTest {
         val room = FakeMatrixRoom(
             typingNoticeResult = { Result.success(Unit) }
         )
-        val presenter = createPresenter(this, room = room)
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
+        val presenter = createPresenter(
+            coroutineScope = this,
+            room = room,
+            navigator = navigator,
+        )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
-            val sendingState = awaitItem()
-            assertThat(sendingState.showAttachmentSourcePicker).isFalse()
-            assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -828,19 +849,22 @@ class MessageComposerPresenterTest {
             typingNoticeResult = { Result.success(Unit) }
         )
         val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
         val presenter = createPresenter(
-            this,
+            coroutineScope = this,
             room = room,
             permissionPresenter = permissionPresenter,
+            navigator = navigator,
         )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
-            val finalState = awaitItem()
-            assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
-            cancelAndIgnoreRemainingEvents()
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -850,23 +874,23 @@ class MessageComposerPresenterTest {
             typingNoticeResult = { Result.success(Unit) }
         )
         val permissionPresenter = FakePermissionsPresenter()
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
         val presenter = createPresenter(
-            this,
+            coroutineScope = this,
             room = room,
             permissionPresenter = permissionPresenter,
+            navigator = navigator,
         )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
-            val permissionState = awaitItem()
-            assertThat(permissionState.showAttachmentSourcePicker).isFalse()
-            assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
             permissionPresenter.setPermissionGranted()
-            skipItems(1)
-            val finalState = awaitItem()
-            assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
             cancelAndIgnoreRemainingEvents()
         }
     }
@@ -877,19 +901,22 @@ class MessageComposerPresenterTest {
             typingNoticeResult = { Result.success(Unit) }
         )
         val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
         val presenter = createPresenter(
-            this,
+            coroutineScope = this,
             room = room,
             permissionPresenter = permissionPresenter,
+            navigator = navigator,
         )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitFirstItem()
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
-            val finalState = awaitItem()
-            assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
-            cancelAndIgnoreRemainingEvents()
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -899,10 +926,15 @@ class MessageComposerPresenterTest {
             typingNoticeResult = { Result.success(Unit) }
         )
         val permissionPresenter = FakePermissionsPresenter()
+        val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
+        val navigator = FakeMessagesNavigator(
+            onPreviewAttachmentLambda = onPreviewAttachmentLambda
+        )
         val presenter = createPresenter(
-            this,
+            coroutineScope = this,
             room = room,
             permissionPresenter = permissionPresenter,
+            navigator = navigator,
         )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
@@ -911,12 +943,9 @@ class MessageComposerPresenterTest {
             initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
             val permissionState = awaitItem()
             assertThat(permissionState.showAttachmentSourcePicker).isFalse()
-            assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
             permissionPresenter.setPermissionGranted()
             skipItems(1)
-            val finalState = awaitItem()
-            assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
-            cancelAndIgnoreRemainingEvents()
+            onPreviewAttachmentLambda.assertions().isCalledOnce()
         }
     }
 
@@ -1500,6 +1529,7 @@ class MessageComposerPresenterTest {
         room: MatrixRoom = FakeMatrixRoom(
             typingNoticeResult = { Result.success(Unit) }
         ),
+        navigator: MessagesNavigator = FakeMessagesNavigator(),
         pickerProvider: PickerProvider = this.pickerProvider,
         featureFlagService: FeatureFlagService = this.featureFlagService,
         sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
@@ -1514,6 +1544,7 @@ class MessageComposerPresenterTest {
         isRichTextEditorEnabled: Boolean = true,
         draftService: ComposerDraftService = FakeComposerDraftService(),
     ) = MessageComposerPresenter(
+        navigator = navigator,
         appCoroutineScope = coroutineScope,
         room = room,
         mediaPickerProvider = pickerProvider,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index d153dd57437..a21dcb28345 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -431,7 +431,10 @@ import kotlin.time.Duration.Companion.seconds
 
     @Test
     fun `present - PollEditClicked event navigates`() = runTest {
-        val navigator = FakeMessagesNavigator()
+        val onEditPollClickLambda = lambdaRecorder { _: EventId -> }
+        val navigator = FakeMessagesNavigator(
+            onEditPollClickLambda = onEditPollClickLambda
+        )
         val presenter = createTimelinePresenter(
             messagesNavigator = navigator,
         )
@@ -439,7 +442,7 @@ import kotlin.time.Duration.Companion.seconds
             presenter.present()
         }.test {
             awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
-            assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
+            onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
         }
     }
 
@@ -657,35 +660,35 @@ import kotlin.time.Duration.Companion.seconds
     private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
         return awaitItem()
     }
-}
 
-internal fun TestScope.createTimelinePresenter(
-    timeline: Timeline = FakeTimeline(),
-    room: FakeMatrixRoom = FakeMatrixRoom(
-        liveTimeline = timeline,
-        canUserSendMessageResult = { _, _ -> Result.success(true) }
-    ),
-    redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
-    messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
-    endPollAction: EndPollAction = FakeEndPollAction(),
-    sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
-    sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
-    timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
-): TimelinePresenter {
-    return TimelinePresenter(
-        timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
-        room = room,
-        dispatchers = testCoroutineDispatchers(),
-        appScope = this,
-        navigator = messagesNavigator,
-        redactedVoiceMessageManager = redactedVoiceMessageManager,
-        endPollAction = endPollAction,
-        sendPollResponseAction = sendPollResponseAction,
-        sessionPreferencesStore = sessionPreferencesStore,
-        timelineItemIndexer = timelineItemIndexer,
-        timelineController = TimelineController(room),
-        resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
-        typingNotificationPresenter = { aTypingNotificationState() },
-        roomCallStatePresenter = { aStandByCallState() },
-    )
+    private fun TestScope.createTimelinePresenter(
+        timeline: Timeline = FakeTimeline(),
+        room: FakeMatrixRoom = FakeMatrixRoom(
+            liveTimeline = timeline,
+            canUserSendMessageResult = { _, _ -> Result.success(true) }
+        ),
+        redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
+        messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
+        endPollAction: EndPollAction = FakeEndPollAction(),
+        sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
+        sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
+        timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
+    ): TimelinePresenter {
+        return TimelinePresenter(
+            timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
+            room = room,
+            dispatchers = testCoroutineDispatchers(),
+            appScope = this,
+            navigator = messagesNavigator,
+            redactedVoiceMessageManager = redactedVoiceMessageManager,
+            endPollAction = endPollAction,
+            sendPollResponseAction = sendPollResponseAction,
+            sessionPreferencesStore = sessionPreferencesStore,
+            timelineItemIndexer = timelineItemIndexer,
+            timelineController = TimelineController(room),
+            resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
+            typingNotificationPresenter = { aTypingNotificationState() },
+            roomCallStatePresenter = { aStandByCallState() },
+        )
+    }
 }