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 d387bc8765..245b23cf8e 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 @@ -84,6 +84,7 @@ internal fun MessageComposerView( val onSendVoiceMessage = { voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + state.eventSink(MessageComposerEvent.CloseSpecialMode) } val onDeleteVoiceMessage = { 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..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 @@ -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?) { val finishedState = recorderState as? VoiceRecorderState.Finished if (finishedState == null) { val exception = VoiceMessageException.FileException("No file to send") @@ -170,6 +172,7 @@ class DefaultVoiceMessageComposerPresenter( file = finishedState.file, mimeType = finishedState.mimeType, waveform = finishedState.waveform, + inReplyToEventId = inReplyToEventId, ) if (result.isFailure) { showSendFailureDialog = true @@ -183,8 +186,13 @@ 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 reply info eagerly before any coroutine dispatch, since CloseSpecialMode + // may reset composerMode before the coroutine runs. + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + localCoroutineScope.launch { + sendVoiceMessage(inReplyToEventId) + } } VoiceMessageComposerEvent.DeleteVoiceMessage -> { player.pause() @@ -280,11 +288,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) { 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..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 @@ -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,37 @@ class DefaultVoiceMessageComposerPresenterTest { } } + @Test + fun `present - send voice message passes reply event ID only when in reply mode`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + // First send in Normal mode (default composerMode). + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + val idleAfterFirstSend = awaitItem() + assertThat(idleAfterFirstSend.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + // Switching to reply mode does not trigger recomposition, so reuse the prior eventSink. + messageComposerContext.composerMode = aReplyMode() + idleAfterFirstSend.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + 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() 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..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 @@ -400,6 +400,7 @@ fun TextComposer( onAddAttachment = onAddAttachment, onDeleteVoiceMessage = onDeleteVoiceMessage, onVoiceRecorderEvent = onVoiceRecorderEvent, + onResetComposerMode = onResetComposerMode, ) } @@ -409,6 +410,15 @@ 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) { 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() }