Show error dialog when voice message fails to send (#1796)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
c7d308f6dd
commit
638fdf495c
10 changed files with 128 additions and 10 deletions
|
|
@ -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.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
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.aVoiceMessageComposerState
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -65,7 +66,14 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
),
|
),
|
||||||
aMessagesState().copy(
|
aMessagesState().copy(
|
||||||
isCallOngoing = true,
|
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.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
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.VoiceMessagePermissionRationaleDialog
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||||
|
|
@ -340,6 +341,11 @@ private fun MessagesViewContent(
|
||||||
appName = state.appName
|
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.
|
// 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.
|
// 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 AcceptPermissionRationale: VoiceMessageComposerEvents
|
||||||
data object DismissPermissionsRationale: VoiceMessageComposerEvents
|
data object DismissPermissionsRationale: VoiceMessageComposerEvents
|
||||||
data class LifecycleEvent(val event: Lifecycle.Event): 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()
|
val permissionState = permissionsPresenter.present()
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
|
var showSendFailureDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(recorderState) {
|
LaunchedEffect(recorderState) {
|
||||||
val recording = recorderState as? VoiceRecorderState.Finished
|
val recording = recorderState as? VoiceRecorderState.Finished
|
||||||
|
|
@ -138,6 +139,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
permissionState.eventSink(PermissionsEvents.CloseDialog)
|
permissionState.eventSink(PermissionsEvents.CloseDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onDismissSendFailureDialog = {
|
||||||
|
showSendFailureDialog = false
|
||||||
|
}
|
||||||
|
|
||||||
val onSendButtonPress = lambda@{
|
val onSendButtonPress = lambda@{
|
||||||
val finishedState = recorderState as? VoiceRecorderState.Finished
|
val finishedState = recorderState as? VoiceRecorderState.Finished
|
||||||
if (finishedState == null) {
|
if (finishedState == null) {
|
||||||
|
|
@ -152,11 +157,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
isSending = true
|
isSending = true
|
||||||
player.pause()
|
player.pause()
|
||||||
analyticsService.captureComposerEvent()
|
analyticsService.captureComposerEvent()
|
||||||
appCoroutineScope.sendMessage(
|
appCoroutineScope.launch {
|
||||||
file = finishedState.file,
|
val result = sendMessage(
|
||||||
mimeType = finishedState.mimeType,
|
file = finishedState.file,
|
||||||
waveform = finishedState.waveform,
|
mimeType = finishedState.mimeType,
|
||||||
).invokeOnCompletion {
|
waveform = finishedState.waveform,
|
||||||
|
)
|
||||||
|
if (result.isFailure) {
|
||||||
|
showSendFailureDialog = true
|
||||||
|
}
|
||||||
|
}.invokeOnCompletion {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
|
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
|
||||||
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
|
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
|
||||||
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
|
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
|
||||||
|
VoiceMessageComposerEvents.DismissSendFailureDialog -> onDismissSendFailureDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +204,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
else -> VoiceMessageState.Idle
|
else -> VoiceMessageState.Idle
|
||||||
},
|
},
|
||||||
showPermissionRationaleDialog = permissionState.showDialog,
|
showPermissionRationaleDialog = permissionState.showDialog,
|
||||||
|
showSendFailureDialog = showSendFailureDialog,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
eventSink = handleEvents,
|
eventSink = handleEvents,
|
||||||
)
|
)
|
||||||
|
|
@ -239,11 +251,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
voiceRecorder.deleteRecording()
|
voiceRecorder.deleteRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.sendMessage(
|
private suspend fun sendMessage(
|
||||||
file: File,
|
file: File,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
waveform: List<Float>
|
waveform: List<Float>,
|
||||||
) = launch {
|
): Result<Unit> {
|
||||||
val result = mediaSender.sendVoiceMessage(
|
val result = mediaSender.sendVoiceMessage(
|
||||||
uri = file.toUri(),
|
uri = file.toUri(),
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
|
@ -252,10 +264,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
Timber.e(result.exceptionOrNull(), "Voice message error")
|
Timber.e(result.exceptionOrNull(), "Voice message error")
|
||||||
return@launch
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceRecorder.deleteRecording()
|
voiceRecorder.deleteRecording()
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AnalyticsService.captureComposerEvent() =
|
private fun AnalyticsService.captureComposerEvent() =
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
data class VoiceMessageComposerState(
|
data class VoiceMessageComposerState(
|
||||||
val voiceMessageState: VoiceMessageState,
|
val voiceMessageState: VoiceMessageState,
|
||||||
val showPermissionRationaleDialog: Boolean,
|
val showPermissionRationaleDialog: Boolean,
|
||||||
|
val showSendFailureDialog: Boolean,
|
||||||
val keepScreenOn: Boolean,
|
val keepScreenOn: Boolean,
|
||||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.composer
|
package io.element.android.features.messages.impl.voicemessages.composer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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 io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -32,13 +33,24 @@ internal fun aVoiceMessageComposerState(
|
||||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||||
keepScreenOn: Boolean = false,
|
keepScreenOn: Boolean = false,
|
||||||
showPermissionRationaleDialog: Boolean = false,
|
showPermissionRationaleDialog: Boolean = false,
|
||||||
|
showSendFailureDialog: Boolean = false,
|
||||||
) = VoiceMessageComposerState(
|
) = VoiceMessageComposerState(
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = voiceMessageState,
|
||||||
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
||||||
|
showSendFailureDialog = showSendFailureDialog,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
eventSink = {},
|
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()
|
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
|
@Test
|
||||||
fun `present - send error - missing recording is tracked`() = runTest {
|
fun `present - send error - missing recording is tracked`() = runTest {
|
||||||
val presenter = createVoiceMessageComposerPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:083476cb480c6641924331689121581300247fd9d1f61c155a35389a07601c69
|
||||||
|
size 51237
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:33b0fb2e0990a008ea6699a0d0fc59478bc3ce755637584af4e2577fc007e2c3
|
||||||
|
size 45809
|
||||||
Loading…
Add table
Add a link
Reference in a new issue