Show error dialog when voice message fails to send (#1796)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
@@ -65,7 +66,14 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
),
|
||||
aMessagesState().copy(
|
||||
isCallOngoing = true,
|
||||
)
|
||||
),
|
||||
aMessagesState().copy(
|
||||
enableVoiceMessages = true,
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(
|
||||
voiceMessageState = aVoiceMessagePreviewState(),
|
||||
showSendFailureDialog = true
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||
@@ -340,6 +341,11 @@ private fun MessagesViewContent(
|
||||
appName = state.appName
|
||||
)
|
||||
}
|
||||
if (state.enableVoiceMessages && state.voiceMessageComposerState.showSendFailureDialog) {
|
||||
VoiceMessageSendingFailedDialog(
|
||||
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) },
|
||||
)
|
||||
}
|
||||
|
||||
// This key is used to force the sheet to be remeasured when the content changes.
|
||||
// Any state change that should trigger a height size should be added to the list of remembered values here.
|
||||
|
||||
@@ -32,4 +32,5 @@ sealed interface VoiceMessageComposerEvents {
|
||||
data object AcceptPermissionRationale: VoiceMessageComposerEvents
|
||||
data object DismissPermissionsRationale: VoiceMessageComposerEvents
|
||||
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
|
||||
data object DismissSendFailureDialog: VoiceMessageComposerEvents
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
|
||||
val permissionState = permissionsPresenter.present()
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
var showSendFailureDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(recorderState) {
|
||||
val recording = recorderState as? VoiceRecorderState.Finished
|
||||
@@ -138,6 +139,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
permissionState.eventSink(PermissionsEvents.CloseDialog)
|
||||
}
|
||||
|
||||
val onDismissSendFailureDialog = {
|
||||
showSendFailureDialog = false
|
||||
}
|
||||
|
||||
val onSendButtonPress = lambda@{
|
||||
val finishedState = recorderState as? VoiceRecorderState.Finished
|
||||
if (finishedState == null) {
|
||||
@@ -152,11 +157,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
isSending = true
|
||||
player.pause()
|
||||
analyticsService.captureComposerEvent()
|
||||
appCoroutineScope.sendMessage(
|
||||
file = finishedState.file,
|
||||
mimeType = finishedState.mimeType,
|
||||
waveform = finishedState.waveform,
|
||||
).invokeOnCompletion {
|
||||
appCoroutineScope.launch {
|
||||
val result = sendMessage(
|
||||
file = finishedState.file,
|
||||
mimeType = finishedState.mimeType,
|
||||
waveform = finishedState.waveform,
|
||||
)
|
||||
if (result.isFailure) {
|
||||
showSendFailureDialog = true
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
@@ -175,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
|
||||
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
|
||||
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
|
||||
VoiceMessageComposerEvents.DismissSendFailureDialog -> onDismissSendFailureDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +204,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
else -> VoiceMessageState.Idle
|
||||
},
|
||||
showPermissionRationaleDialog = permissionState.showDialog,
|
||||
showSendFailureDialog = showSendFailureDialog,
|
||||
keepScreenOn = keepScreenOn,
|
||||
eventSink = handleEvents,
|
||||
)
|
||||
@@ -239,11 +251,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
voiceRecorder.deleteRecording()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
private suspend fun sendMessage(
|
||||
file: File,
|
||||
mimeType: String,
|
||||
waveform: List<Float>
|
||||
) = launch {
|
||||
waveform: List<Float>,
|
||||
): Result<Unit> {
|
||||
val result = mediaSender.sendVoiceMessage(
|
||||
uri = file.toUri(),
|
||||
mimeType = mimeType,
|
||||
@@ -252,10 +264,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
|
||||
if (result.isFailure) {
|
||||
Timber.e(result.exceptionOrNull(), "Voice message error")
|
||||
return@launch
|
||||
return result
|
||||
}
|
||||
|
||||
voiceRecorder.deleteRecording()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun AnalyticsService.captureComposerEvent() =
|
||||
|
||||
@@ -23,6 +23,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val showPermissionRationaleDialog: Boolean,
|
||||
val showSendFailureDialog: Boolean,
|
||||
val keepScreenOn: Boolean,
|
||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -32,13 +33,24 @@ internal fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
keepScreenOn: Boolean = false,
|
||||
showPermissionRationaleDialog: Boolean = false,
|
||||
showSendFailureDialog: Boolean = false,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
||||
showSendFailureDialog = showSendFailureDialog,
|
||||
keepScreenOn = keepScreenOn,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
internal fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
|
||||
isSending = false,
|
||||
isPlaying = false,
|
||||
showCursor = false,
|
||||
playbackProgress = 0f,
|
||||
time = 10.seconds,
|
||||
waveform = createFakeWaveform(),
|
||||
)
|
||||
|
||||
internal var aWaveformLevels = List(100) { it.toFloat() / 100 }.toPersistentList()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun VoiceMessageSendingFailedDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ErrorDialog(
|
||||
title = stringResource(CommonStrings.common_error),
|
||||
content = stringResource(CommonStrings.error_failed_uploading_voice_message),
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(CommonStrings.action_ok),
|
||||
)
|
||||
}
|
||||
@@ -437,6 +437,42 @@ class VoiceMessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send failures are displayed as an error dialog`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Let sending fail due to media preprocessing error
|
||||
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||
assertThat(showSendFailureDialog).isTrue()
|
||||
}
|
||||
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
assertThat(showSendFailureDialog).isTrue()
|
||||
eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog)
|
||||
}
|
||||
|
||||
val finalState = awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
assertThat(showSendFailureDialog).isFalse()
|
||||
}
|
||||
|
||||
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send error - missing recording is tracked`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user