Show loading and failure feedback when updating user status

This commit is contained in:
ganfra
2026-08-06 16:15:44 +02:00
parent c35a0a3864
commit a7d2b86fe0
7 changed files with 369 additions and 63 deletions
@@ -9,9 +9,12 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -26,7 +29,10 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -73,9 +79,9 @@ fun PreferencesRootView(
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) {
// Include pref from other modules // Include pref from other modules
PreferencePage( PreferencePage(
modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings), title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
@@ -137,6 +143,22 @@ fun PreferencesRootView(
} }
) )
} }
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
}
}
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
} }
@Composable @Composable
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState() val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) { fun handleEvent(event: UserStatusEvent) {
when (event) { when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> { is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
} }
UserStatusEvent.ClearStatus -> { UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
} }
UserStatusEvent.OpenCustomInput -> { UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus, displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus, rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState, pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?, val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?, val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState, val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit, val eventSink: (UserStatusEvent) -> Unit,
) )
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null, displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null, rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {}, eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState( ) = UserStatusState(
displayedStatus = displayedStatus, displayedStatus = displayedStatus,
rawStatus = rawStatus, rawStatus = rawStatus,
pickerState = pickerState, pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem() val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue() assertThat(client.setUserStatusCalled).isTrue()
} }
} }
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull() assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue() assertThat(client.clearUserStatusCalled).isTrue()
} }
} }
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private val DefaultShowDelay = 300.milliseconds
private val DefaultMinDisplayDuration = 500.milliseconds
private val DefaultFailureDuration = 500.milliseconds
/**
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
*
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
* prevents the indicator from flashing for operations that complete almost instantly.
*
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
* never debounced) and auto-dismisses after [failureDuration].
*
* @param asyncAction the action whose progress drives the indicator.
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
* @param state the [AsyncIndicatorState] backing the host.
* @param showDelay how long the loading state must persist before the indicator is shown.
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
* @param loading the composable to display while loading.
* @param failure the composable to display on failure.
*/
@Composable
fun AsyncActionIndicator(
asyncAction: AsyncAction<*>,
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
showDelay: Duration = DefaultShowDelay,
minDisplayDuration: Duration = DefaultMinDisplayDuration,
failureDuration: Duration = DefaultFailureDuration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
) {
AsyncIndicatorHost(modifier = modifier, state = state)
AsyncActionIndicatorEffect(
asyncAction = asyncAction,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplayDuration,
failureDuration = failureDuration,
loading = loading,
failure = failure,
)
}
@Composable
internal fun AsyncActionIndicatorEffect(
asyncAction: AsyncAction<*>,
state: AsyncIndicatorState,
showDelay: Duration,
minDisplayDuration: Duration,
failureDuration: Duration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
timeSource: TimeSource = TimeSource.Monotonic,
) {
// Mark set when the loader actually becomes visible, null while it is hidden.
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
LaunchedEffect(asyncAction) {
when (asyncAction) {
is AsyncAction.Loading -> {
// If the operation completes before this delay elapses, the effect is cancelled
// and the loader is never shown.
delay(showDelay)
state.enqueue(composable = loading)
loaderShownAt = timeSource.markNow()
}
is AsyncAction.Failure -> {
// Always surface failures immediately, even if the loader was never shown.
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { failure(asyncAction.error) })
loaderShownAt = null
}
is AsyncAction.Success -> {
// Hold the loader for its minimum duration before clearing it.
loaderShownAt?.let { shownAt ->
val remaining = minDisplayDuration - shownAt.elapsedNow()
if (remaining > Duration.ZERO) delay(remaining)
}
state.clear()
loaderShownAt = null
}
else -> Unit
}
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.component.async
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncActionIndicatorTest {
private val showDelay = 300.milliseconds
private val minDisplay = 500.milliseconds
@Test
fun `loader is not shown before the show delay elapses`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
// Once the show delay has fully elapsed, the loader is shown.
advanceTimeBy(300.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader is never shown when the operation completes before the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
action.value = AsyncAction.Success(Unit)
advanceTimeBy(1000.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Failure(Exception("failed"))
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader stays visible for the minimum duration once shown`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
val timeSource = TestTimeSource()
indicatorFlow(action, state, timeSource).test {
// Show the loader.
action.value = AsyncAction.Loading
advanceTimeBy(400.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// 200ms of display has already elapsed when the operation succeeds.
timeSource += 200.milliseconds
action.value = AsyncAction.Success(Unit)
runCurrent()
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// Past the minimum display duration -> cleared.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun indicatorFlow(
action: State<AsyncAction<*>>,
state: AsyncIndicatorState,
timeSource: TimeSource = TimeSource.Monotonic,
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
AsyncActionIndicatorEffect(
asyncAction = action.value,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplay,
failureDuration = minDisplay,
loading = {},
failure = {},
timeSource = timeSource,
)
state.currentAnimationState.targetState
}
}