Merge pull request #6464 from kalix127/feat/reply-with-voice-message

Support replying to messages with voice recordings
This commit is contained in:
Benoit Marty 2026-04-17 14:49:04 +02:00 committed by GitHub
commit 213d4c2b87
4 changed files with 68 additions and 3 deletions

View file

@ -84,6 +84,7 @@ internal fun MessageComposerView(
val onSendVoiceMessage = { val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
state.eventSink(MessageComposerEvent.CloseSpecialMode)
} }
val onDeleteVoiceMessage = { val onDeleteVoiceMessage = {

View file

@ -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.audio.api.AudioFocusRequester
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter 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.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState 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 val finishedState = recorderState as? VoiceRecorderState.Finished
if (finishedState == null) { if (finishedState == null) {
val exception = VoiceMessageException.FileException("No file to send") val exception = VoiceMessageException.FileException("No file to send")
@ -170,6 +172,7 @@ class DefaultVoiceMessageComposerPresenter(
file = finishedState.file, file = finishedState.file,
mimeType = finishedState.mimeType, mimeType = finishedState.mimeType,
waveform = finishedState.waveform, waveform = finishedState.waveform,
inReplyToEventId = inReplyToEventId,
) )
if (result.isFailure) { if (result.isFailure) {
showSendFailureDialog = true showSendFailureDialog = true
@ -183,8 +186,13 @@ class DefaultVoiceMessageComposerPresenter(
when (event) { when (event) {
is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch { is VoiceMessageComposerEvent.SendVoiceMessage -> {
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 -> { VoiceMessageComposerEvent.DeleteVoiceMessage -> {
player.pause() player.pause()
@ -280,11 +288,13 @@ class DefaultVoiceMessageComposerPresenter(
file: File, file: File,
mimeType: String, mimeType: String,
waveform: List<Float>, waveform: List<Float>,
inReplyToEventId: EventId? = null,
): Result<Unit> { ): Result<Unit> {
val result = mediaSender.sendVoiceMessage( val result = mediaSender.sendVoiceMessage(
uri = file.toUri(), uri = file.toUri(),
mimeType = mimeType, mimeType = mimeType,
waveForm = waveform, waveForm = waveform,
inReplyToEventId = inReplyToEventId,
) )
if (result.isFailure) { if (result.isFailure) {

View file

@ -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.core.EventId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.timeline.Timeline 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.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -59,6 +62,7 @@ import java.io.File
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class DefaultVoiceMessageComposerPresenterTest { class DefaultVoiceMessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() 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 @Test
fun `present - send while playing`() = runTest { fun `present - send while playing`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()

View file

@ -400,6 +400,7 @@ fun TextComposer(
onAddAttachment = onAddAttachment, onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent, onVoiceRecorderEvent = onVoiceRecorderEvent,
onResetComposerMode = onResetComposerMode,
) )
} }
@ -409,6 +410,15 @@ fun TextComposer(
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } 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) val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
if (state is TextEditorState.Rich) { if (state is TextEditorState.Rich) {
val menuAction = state.richTextEditorState.menuAction val menuAction = state.richTextEditorState.menuAction
@ -440,6 +450,7 @@ private fun StandardLayout(
onAddAttachment: () -> Unit, onAddAttachment: () -> Unit,
onDeleteVoiceMessage: () -> Unit, onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit, onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -506,6 +517,14 @@ private fun StandardLayout(
) { ) {
if (voiceMessageState is VoiceMessageState.Idle) { if (voiceMessageState is VoiceMessageState.Idle) {
textInput() textInput()
} else if (composerMode is MessageComposerMode.Special) {
TextInputBox(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
isTextEmpty = true,
) {
voiceRecording()
}
} else { } else {
voiceRecording() voiceRecording()
} }