Send UI errors to a channel instead of a shared flow (#3652)
In the previous code any errors that occured *before* a subscriber was listening to `uiError` would be dropped, so the user would be unware of them. By implementing as a channel these errors will be shown to the user, with an opportunity to retry the operation or report the error.
This commit is contained in:
parent
5e8a63a046
commit
8fec41c2ae
2 changed files with 46 additions and 36 deletions
|
@ -267,7 +267,7 @@ class NotificationsFragment :
|
|||
Log.d(TAG, error.toString())
|
||||
val message = getString(
|
||||
error.message,
|
||||
error.exception.localizedMessage
|
||||
error.throwable.localizedMessage
|
||||
?: getString(R.string.ui_error_unknown)
|
||||
)
|
||||
val snackbar = Snackbar.make(
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
|
@ -46,6 +47,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -60,6 +62,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
|
@ -220,7 +223,7 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
|
|||
/** Errors from fallible view model actions that the UI will need to show */
|
||||
sealed class UiError(
|
||||
/** The exception associated with the error */
|
||||
open val exception: Exception,
|
||||
open val throwable: Throwable,
|
||||
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val message: Int,
|
||||
|
@ -228,50 +231,50 @@ sealed class UiError(
|
|||
/** The action that failed. Can be resent to retry the action */
|
||||
open val action: UiAction? = null
|
||||
) {
|
||||
data class ClearNotifications(override val exception: Exception) : UiError(
|
||||
exception,
|
||||
data class ClearNotifications(override val throwable: Throwable) : UiError(
|
||||
throwable,
|
||||
R.string.ui_error_clear_notifications
|
||||
)
|
||||
|
||||
data class Bookmark(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Bookmark
|
||||
) : UiError(exception, R.string.ui_error_bookmark, action)
|
||||
) : UiError(throwable, R.string.ui_error_bookmark, action)
|
||||
|
||||
data class Favourite(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Favourite
|
||||
) : UiError(exception, R.string.ui_error_favourite, action)
|
||||
) : UiError(throwable, R.string.ui_error_favourite, action)
|
||||
|
||||
data class Reblog(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Reblog
|
||||
) : UiError(exception, R.string.ui_error_reblog, action)
|
||||
) : UiError(throwable, R.string.ui_error_reblog, action)
|
||||
|
||||
data class VoteInPoll(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.VoteInPoll
|
||||
) : UiError(exception, R.string.ui_error_vote, action)
|
||||
) : UiError(throwable, R.string.ui_error_vote, action)
|
||||
|
||||
data class AcceptFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.AcceptFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_accept_follow_request, action)
|
||||
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
|
||||
|
||||
data class RejectFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.RejectFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_reject_follow_request, action)
|
||||
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(exception, action)
|
||||
is StatusAction.Favourite -> Favourite(exception, action)
|
||||
is StatusAction.Reblog -> Reblog(exception, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
|
||||
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(throwable, action)
|
||||
is StatusAction.Favourite -> Favourite(throwable, action)
|
||||
is StatusAction.Reblog -> Reblog(throwable, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -298,14 +301,21 @@ class NotificationsViewModel @Inject constructor(
|
|||
private val uiAction = MutableSharedFlow<UiAction>()
|
||||
|
||||
/** Flow of successful action results */
|
||||
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not
|
||||
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
|
||||
// flow (e.g., after a device orientation change) should not re-show the most recent success or
|
||||
// error message, as it will be confusing to the user.
|
||||
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
|
||||
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
|
||||
// (e.g., after a device orientation change) should not re-show the most recent success
|
||||
// message, as it will be confusing to the user.
|
||||
val uiSuccess = MutableSharedFlow<UiSuccess>()
|
||||
|
||||
/** Flow of transient errors for the UI to present */
|
||||
val uiError = MutableSharedFlow<UiError>()
|
||||
/** Channel for error results */
|
||||
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
|
||||
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
|
||||
// was a StateFlow any errors would be retained, and there would need to be an explicit
|
||||
// mechanism to dismiss them.
|
||||
private val _uiErrorChannel = Channel<UiError>()
|
||||
|
||||
/** Expose UI errors as a flow */
|
||||
val uiError = _uiErrorChannel.receiveAsFlow()
|
||||
|
||||
/** Accept UI actions in to actionStateFlow */
|
||||
val accept: (UiAction) -> Unit = { action ->
|
||||
|
@ -380,11 +390,11 @@ class NotificationsViewModel @Inject constructor(
|
|||
if (this.isSuccessful) {
|
||||
repository.invalidate()
|
||||
} else {
|
||||
uiError.emit(UiError.make(HttpException(this), it))
|
||||
_uiErrorChannel.send(UiError.make(HttpException(this), it))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, it)) }
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -403,7 +413,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
uiSuccess.emit(NotificationActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -436,10 +446,10 @@ class NotificationsViewModel @Inject constructor(
|
|||
action.poll.id,
|
||||
action.choices
|
||||
)
|
||||
}
|
||||
}.getOrThrow()
|
||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
} catch (t: Throwable) {
|
||||
_uiErrorChannel.send(UiError.make(t, action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue