diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 7ec711fdeb..2e449f5820 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -9,9 +9,12 @@ package io.element.android.features.preferences.impl.root 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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.userstatus.UserStatusState 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.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.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage @@ -73,72 +79,88 @@ fun PreferencesRootView( ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) - // Include pref from other modules - PreferencePage( - modifier = modifier, - onBackClick = onBackClick, - title = stringResource(id = CommonStrings.common_settings), - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { - UserPreferences( - modifier = Modifier.clickable { - onOpenUserProfile(state.myUser) - }, - matrixUser = state.myUser, - ) - if (state.isMultiAccountEnabled) { - MultiAccountSection( - state = state, - onAddAccountClick = onAddAccountClick, + Box(modifier = modifier) { + // Include pref from other modules + PreferencePage( + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_settings), + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { + UserPreferences( + modifier = Modifier.clickable { + onOpenUserProfile(state.myUser) + }, + matrixUser = state.myUser, ) - } - if (state.userStatusState != null) { - UserStatusSection( - userStatusState = state.userStatusState, - emojiPickerRenderer = emojiPickerRenderer, - showTopDivider = !state.isMultiAccountEnabled, - ) - } - // 'Account' section - ManageAccountSection( - state = state, - onManageAccountClick = onManageAccountClick, - onLinkNewDeviceClick = onLinkNewDeviceClick, - onOpenBlockedUsers = onOpenBlockedUsers - ) - // 'Manage my app' section - ManageAppSection( - state = state, - onOpenNotificationSettings = onOpenNotificationSettings, - onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClick = onSecureBackupClick, - ) - - // General section - GeneralSection( - state = state, - onOpenAbout = onOpenAbout, - onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, - onOpenAdvancedSettings = onOpenAdvancedSettings, - onOpenDeveloperSettings = onOpenDeveloperSettings, - onOpenLabs = onOpenLabs, - onSignOutClick = onSignOutClick, - onDeactivateClick = onDeactivateClick, - ) - // Version - Footer( - version = state.version, - deviceId = state.deviceId, - onClick = if (!state.showDeveloperSettings) { - { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) } - } else { - null + if (state.isMultiAccountEnabled) { + MultiAccountSection( + state = state, + onAddAccountClick = onAddAccountClick, + ) } - ) + if (state.userStatusState != null) { + UserStatusSection( + userStatusState = state.userStatusState, + emojiPickerRenderer = emojiPickerRenderer, + showTopDivider = !state.isMultiAccountEnabled, + ) + } + // 'Account' section + ManageAccountSection( + state = state, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenBlockedUsers = onOpenBlockedUsers + ) + // 'Manage my app' section + ManageAppSection( + state = state, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onSecureBackupClick = onSecureBackupClick, + ) + + // General section + GeneralSection( + state = state, + onOpenAbout = onOpenAbout, + onOpenAnalytics = onOpenAnalytics, + onOpenRageShake = onOpenRageShake, + onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenDeveloperSettings = onOpenDeveloperSettings, + onOpenLabs = onOpenLabs, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + // Version + Footer( + version = state.version, + deviceId = state.deviceId, + onClick = if (!state.showDeveloperSettings) { + { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) } + } else { + null + } + ) + } + state.userStatusState?.let { + UserStatusUpdateIndicator(it.updateStatusAction) + } } } +@Composable +private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction) { + 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 private fun ColumnScope.UserStatusSection( userStatusState: UserStatusState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index 60f02ad05a..2358b3502d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -18,7 +18,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.runUpdatingState import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis import io.element.android.libraries.matrix.api.MatrixClient @@ -38,6 +40,7 @@ class UserStatusPresenter( var isEmojiPickerVisible by remember { mutableStateOf(false) } val customTextFieldState = rememberTextFieldState() val coroutineScope = rememberCoroutineScope() + val updateStatusAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } fun handleEvent(event: UserStatusEvent) { when (event) { @@ -47,11 +50,15 @@ class UserStatusPresenter( UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden is UserStatusEvent.SetStatus -> { pickerState = UserStatusPickerState.Hidden - coroutineScope.launch { matrixClient.setUserStatus(event.status) } + coroutineScope.launch { + updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) } + } } UserStatusEvent.ClearStatus -> { pickerState = UserStatusPickerState.Hidden - coroutineScope.launch { matrixClient.clearUserStatus() } + coroutineScope.launch { + updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() } + } } UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus @@ -93,6 +100,7 @@ class UserStatusPresenter( displayedStatus = userProfile.displayedStatus, rawStatus = userProfile.rawStatus, pickerState = effectivePickerState, + updateStatusAction = updateStatusAction.value, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index fa225ae277..6189ee6dd0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -11,6 +11,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable 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.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus @@ -19,6 +20,7 @@ data class UserStatusState( val displayedStatus: DisplayedStatus?, val rawStatus: UserStatus?, val pickerState: UserStatusPickerState, + val updateStatusAction: AsyncAction, val eventSink: (UserStatusEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt index 13a1d31743..6b198183a7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus import androidx.compose.foundation.text.input.TextFieldState 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.UserStatus @@ -44,10 +45,12 @@ fun aUserStatusState( displayedStatus: DisplayedStatus? = null, rawStatus: UserStatus? = null, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, + updateStatusAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (UserStatusEvent) -> Unit = {}, ) = UserStatusState( displayedStatus = displayedStatus, rawStatus = rawStatus, pickerState = pickerState, + updateStatusAction = updateStatusAction, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index a09afdeb6f..e9897259ae 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -8,9 +8,11 @@ package io.element.android.features.preferences.impl.userstatus 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.matrix.api.user.DisplayedStatus 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.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -61,6 +63,24 @@ class UserStatusPresenterTest { val newState = awaitItem() assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) 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() } } @@ -121,6 +141,25 @@ class UserStatusPresenterTest { val clearedState = awaitItem() assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) 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() } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt new file mode 100644 index 0000000000..058225939f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt @@ -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(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 + } + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt new file mode 100644 index 0000000000..426aa71ce7 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt @@ -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.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.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.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.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>, + state: AsyncIndicatorState, + timeSource: TimeSource = TimeSource.Monotonic, + ): Flow = moleculeFlow(RecompositionMode.Immediate) { + AsyncActionIndicatorEffect( + asyncAction = action.value, + state = state, + showDelay = showDelay, + minDisplayDuration = minDisplay, + failureDuration = minDisplay, + loading = {}, + failure = {}, + timeSource = timeSource, + ) + state.currentAnimationState.targetState + } +}