Merge pull request #6464 from kalix127/feat/reply-with-voice-message
Support replying to messages with voice recordings
This commit is contained in:
commit
213d4c2b87
4 changed files with 68 additions and 3 deletions
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue