From bf7ab3151703581e90d56295bfc7807ce97fa3bc Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Wed, 25 Mar 2026 00:53:57 +0100 Subject: [PATCH 1/7] feat: support sending voice messages as replies --- .../DefaultVoiceMessageComposerPresenter.kt | 33 ++++++++++------- ...efaultVoiceMessageComposerPresenterTest.kt | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 6fdd2f1752..a14a471571 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -34,10 +34,12 @@ import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState @@ -151,7 +153,7 @@ class DefaultVoiceMessageComposerPresenter( } } - fun sendVoiceMessage() { + fun sendVoiceMessage(inReplyToEventId: EventId?, composerEvent: Composer) { val finishedState = recorderState as? VoiceRecorderState.Finished if (finishedState == null) { val exception = VoiceMessageException.FileException("No file to send") @@ -164,12 +166,13 @@ class DefaultVoiceMessageComposerPresenter( } isSending = true player.pause() - analyticsService.captureComposerEvent() + analyticsService.capture(composerEvent) sessionCoroutineScope.launch { val result = sendMessage( file = finishedState.file, mimeType = finishedState.mimeType, waveform = finishedState.waveform, + inReplyToEventId = inReplyToEventId, ) if (result.isFailure) { showSendFailureDialog = true @@ -183,8 +186,14 @@ class DefaultVoiceMessageComposerPresenter( when (event) { is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) - is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch { - sendVoiceMessage() + is VoiceMessageComposerEvent.SendVoiceMessage -> { + // Capture mode eagerly before any coroutine dispatch, since CloseSpecialMode + // may reset it before the coroutine runs. + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + val composerEvent = buildComposerEvent() + localCoroutineScope.launch { + sendVoiceMessage(inReplyToEventId, composerEvent) + } } VoiceMessageComposerEvent.DeleteVoiceMessage -> { player.pause() @@ -280,11 +289,13 @@ class DefaultVoiceMessageComposerPresenter( file: File, mimeType: String, waveform: List, + inReplyToEventId: EventId? = null, ): Result { val result = mediaSender.sendVoiceMessage( uri = file.toUri(), mimeType = mimeType, waveForm = waveform, + inReplyToEventId = inReplyToEventId, ) if (result.isFailure) { @@ -297,14 +308,12 @@ class DefaultVoiceMessageComposerPresenter( return result } - private fun AnalyticsService.captureComposerEvent() = - capture( - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.VoiceMessage, - ) + private fun buildComposerEvent() = + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.VoiceMessage, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 9c55bf3a85..5323ccd6f0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline @@ -46,7 +47,9 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,6 +62,7 @@ import java.io.File import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +@Suppress("LargeClass") class DefaultVoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -406,6 +410,38 @@ class DefaultVoiceMessageComposerPresenterTest { } } + @Test + fun `present - send voice message passes reply event ID only when in reply mode`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + // Send without reply - should pass null + messageComposerContext.composerMode = MessageComposerMode.Normal + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + skipItems(1) // Sending state + advanceUntilIdle() + + sendVoiceMessageResult.assertions().isCalledOnce() + .with(any(), any(), any(), value(null)) + + // Send as reply - should pass event ID + messageComposerContext.composerMode = aReplyMode() + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + val finalState = awaitItem() // Sending state + + sendVoiceMessageResult.assertions().isCalledExactly(2) + .withSequence( + listOf(any(), any(), any(), value(null)), + listOf(any(), any(), any(), value(AN_EVENT_ID)), + ) + + testPauseAndDestroy(finalState) + } + } + @Test fun `present - send while playing`() = runTest { val presenter = createDefaultVoiceMessageComposerPresenter() From 4a5662a5e25547f21b588a7aeeca99a6af9b48f8 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Wed, 25 Mar 2026 00:55:57 +0100 Subject: [PATCH 2/7] fix: reset composer mode after sending voice message reply --- .../messages/impl/messagecomposer/MessageComposerView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 4b346e0c15..48baf245fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -83,6 +83,7 @@ internal fun MessageComposerView( val onSendVoiceMessage = { voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + state.eventSink(MessageComposerEvent.CloseSpecialMode) } val onDeleteVoiceMessage = { From d9a54fb716b6edd73e8d03e2130838e1f3570f33 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Wed, 25 Mar 2026 00:58:57 +0100 Subject: [PATCH 3/7] fix: persist reply banner during voice recording and dismiss keyboard --- .../libraries/textcomposer/TextComposer.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index bdaed4e402..360e81e426 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -400,6 +401,7 @@ fun TextComposer( onAddAttachment = onAddAttachment, onDeleteVoiceMessage = onDeleteVoiceMessage, onVoiceRecorderEvent = onVoiceRecorderEvent, + onResetComposerMode = onResetComposerMode, ) } @@ -409,6 +411,14 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } + // Dismiss keyboard when voice recording starts + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(voiceMessageState) { + if (voiceMessageState !is VoiceMessageState.Idle) { + keyboardController?.hide() + } + } + val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) if (state is TextEditorState.Rich) { val menuAction = state.richTextEditorState.menuAction @@ -440,6 +450,7 @@ private fun StandardLayout( onAddAttachment: () -> Unit, onDeleteVoiceMessage: () -> Unit, onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit, + onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -506,6 +517,14 @@ private fun StandardLayout( ) { if (voiceMessageState is VoiceMessageState.Idle) { textInput() + } else if (composerMode is MessageComposerMode.Special) { + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + isTextEmpty = true, + ) { + voiceRecording() + } } else { voiceRecording() } From 37da07a7208b9a9235638f27fc733fe5a2d15949 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Fri, 27 Mar 2026 21:18:55 +0100 Subject: [PATCH 4/7] Revert unnecessary analytics refactor in voice message presenter --- .../DefaultVoiceMessageComposerPresenter.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index a14a471571..96d1787085 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -153,7 +153,7 @@ class DefaultVoiceMessageComposerPresenter( } } - fun sendVoiceMessage(inReplyToEventId: EventId?, composerEvent: Composer) { + fun sendVoiceMessage(inReplyToEventId: EventId?) { val finishedState = recorderState as? VoiceRecorderState.Finished if (finishedState == null) { val exception = VoiceMessageException.FileException("No file to send") @@ -166,7 +166,7 @@ class DefaultVoiceMessageComposerPresenter( } isSending = true player.pause() - analyticsService.capture(composerEvent) + analyticsService.captureComposerEvent() sessionCoroutineScope.launch { val result = sendMessage( file = finishedState.file, @@ -187,12 +187,11 @@ class DefaultVoiceMessageComposerPresenter( is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) is VoiceMessageComposerEvent.SendVoiceMessage -> { - // Capture mode eagerly before any coroutine dispatch, since CloseSpecialMode - // may reset it before the coroutine runs. + // Capture reply info eagerly before any coroutine dispatch, since CloseSpecialMode + // may reset composerMode before the coroutine runs. val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - val composerEvent = buildComposerEvent() localCoroutineScope.launch { - sendVoiceMessage(inReplyToEventId, composerEvent) + sendVoiceMessage(inReplyToEventId) } } VoiceMessageComposerEvent.DeleteVoiceMessage -> { @@ -308,12 +307,14 @@ class DefaultVoiceMessageComposerPresenter( return result } - private fun buildComposerEvent() = - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.VoiceMessage, + private fun AnalyticsService.captureComposerEvent() = + capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.VoiceMessage, + ) ) } From 9a81ec5569bb624e2fa6a85e8f146e023d9c0400 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Thu, 2 Apr 2026 22:14:04 +0200 Subject: [PATCH 5/7] refactor: remove keyboard dismissal logic during voice recording --- .../android/libraries/textcomposer/TextComposer.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 360e81e426..4f61b13fb5 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -411,13 +410,6 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } - // Dismiss keyboard when voice recording starts - val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(voiceMessageState) { - if (voiceMessageState !is VoiceMessageState.Idle) { - keyboardController?.hide() - } - } val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) if (state is TextEditorState.Rich) { From 4586ee31ea0afc75b5ca080abae59b628e5f99c2 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Thu, 2 Apr 2026 22:20:13 +0200 Subject: [PATCH 6/7] fix: re-focus text input after voice recording ends --- .../android/libraries/textcomposer/TextComposer.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 4f61b13fb5..5a3ee1c8a0 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -410,6 +410,14 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } + // Re-focus the text input when voice recording ends so the user can continue typing + var previousVoiceMessageState by remember { mutableStateOf(voiceMessageState) } + LaunchedEffect(voiceMessageState) { + if (voiceMessageState is VoiceMessageState.Idle && previousVoiceMessageState !is VoiceMessageState.Idle) { + onRequestFocus() + } + previousVoiceMessageState = voiceMessageState + } val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) if (state is TextEditorState.Rich) { From 67729f87c911bfc16279cfc8151f75e0d835df0b Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Wed, 15 Apr 2026 18:17:31 +0200 Subject: [PATCH 7/7] test: stabilize reply event ID test for voice message composer --- ...efaultVoiceMessageComposerPresenterTest.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 5323ccd6f0..7d3b6cf647 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -414,23 +414,22 @@ class DefaultVoiceMessageComposerPresenterTest { fun `present - send voice message passes reply event ID only when in reply mode`() = runTest { val presenter = createDefaultVoiceMessageComposerPresenter() presenter.test { - // Send without reply - should pass null - messageComposerContext.composerMode = MessageComposerMode.Normal + // First send in Normal mode (default composerMode). awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) - skipItems(1) // Sending state - advanceUntilIdle() + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + val idleAfterFirstSend = awaitItem() + assertThat(idleAfterFirstSend.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - sendVoiceMessageResult.assertions().isCalledOnce() - .with(any(), any(), any(), value(null)) - - // Send as reply - should pass event ID + // Switching to reply mode does not trigger recomposition, so reuse the prior eventSink. messageComposerContext.composerMode = aReplyMode() - awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + idleAfterFirstSend.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) - val finalState = awaitItem() // Sending state + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledExactly(2) .withSequence(