From 3c4632ee9d1f2d80425e5cd2053e5f6297154ed4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 10:47:30 +0200 Subject: [PATCH] Render an error in the room select screen if the rooms cannot be loaded If the client of the selected account cannot be restored, the room list was never loaded and there was no feedback to the user. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../roomselect/impl/RoomSelectPresenter.kt | 15 ++++--- .../roomselect/impl/RoomSelectState.kt | 4 ++ .../impl/RoomSelectStatePreviewParam.kt | 8 ++++ .../roomselect/impl/RoomSelectView.kt | 18 ++++++++- .../impl/src/main/res/values/localazy.xml | 4 ++ .../impl/RoomSelectPresenterTest.kt | 40 +++++++++++++++++++ .../roomselect/impl/RoomSelectViewTest.kt | 13 ++++++ tools/localazy/config.json | 6 +++ 8 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 libraries/roomselect/impl/src/main/res/values/localazy.xml diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 11e6c65415..0850cbc4e9 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -87,20 +87,22 @@ class RoomSelectPresenter( val coroutineScope = rememberCoroutineScope() // Create one data source per session. When the selected session changes, the previous // data source is removed from the composition and its coroutine scope is cancelled. - val dataSource = key(selectedSessionId) { + val (dataSource, hasRoomListError) = key(selectedSessionId) { val dataSourceCoroutineScope = rememberCoroutineScope() - // The sessions are restored at startup, so the client should already be in memory - val roomListService by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService) { + // The sessions are restored at startup, so the client should already be in memory. + // A null value means that the client is being restored. + val roomListServiceResult by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService?.let { Result.success(it) }) { if (value == null) { value = matrixClientProvider.getOrRestore(selectedSessionId) .onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } - .getOrNull() - ?.roomListService + .map { it.roomListService } } } - remember(roomListService) { + val roomListService = roomListServiceResult?.getOrNull() + val dataSource = remember(roomListService) { roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } } + dataSource to (roomListServiceResult?.isFailure == true) } val searchQuery = queryState.text.toString() @@ -156,6 +158,7 @@ class RoomSelectPresenter( isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, otherAccounts = otherAccounts, isAccountListExpanded = isAccountListExpanded, eventSink = ::handleEvent, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index 198ce2655d..7af7b28ddf 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -23,6 +23,10 @@ data class RoomSelectState( val isSearchActive: Boolean, val selectedRooms: ImmutableList, val selectedAccount: MatrixUser, + /** + * True if the rooms of the selected account cannot be loaded. + */ + val hasRoomListError: Boolean, val otherAccounts: ImmutableList, val isAccountListExpanded: Boolean, val eventSink: (RoomSelectEvent) -> Unit, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index d4e5ba011e..0c46c652ef 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -62,6 +62,12 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider = persistentListOf(), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + hasRoomListError: Boolean = false, otherAccounts: ImmutableList = persistentListOf(), isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, @@ -84,6 +91,7 @@ internal fun aRoomSelectState( isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, otherAccounts = otherAccounts, isAccountListExpanded = isAccountListExpanded, eventSink = eventSink, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 224802c823..5865d4416a 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarRow @@ -240,7 +242,9 @@ fun RoomSelectView( // showVerticalSpace only if there is no other accounts showVerticalSpace = !state.showAccountSwitch, ) - if (state.resultState is SearchBarResultState.Results) { + if (state.hasRoomListError) { + RoomListError() + } else if (state.resultState is SearchBarResultState.Results) { LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, @@ -270,6 +274,18 @@ fun RoomSelectView( } } +@Composable +private fun RoomListError() { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + title = stringResource(CommonStrings.common_something_went_wrong), + subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms), + iconStyle = BigIcon.Style.AlertSolid, + ) +} + /** * Similar to the MultiAccountSection of the PreferencesRootView. * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759 diff --git a/libraries/roomselect/impl/src/main/res/values/localazy.xml b/libraries/roomselect/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..377391b835 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "The room list associated with this account cannot be loaded." + diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 3eeb3397d8..983d676dec 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -63,6 +64,45 @@ class RoomSelectPresenterTest { assertThat(initialState.otherAccounts).isEmpty() assertThat(initialState.isAccountListExpanded).isFalse() assertThat(initialState.showAccountSwitch).isFalse() + assertThat(initialState.hasRoomListError).isFalse() + } + } + + @Test + fun `present - failure to get the client shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(state.hasRoomListError).isTrue() + assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + } + } + + @Test + fun `present - select another account whose client cannot be restored shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) { + Result.success(FakeMatrixClient(sessionId = sessionId)) + } else { + Result.failure(AN_EXCEPTION) + } + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + // Going back to the first account clears the error + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.hasRoomListError).isFalse() } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index 4abdddd8a8..543d20b521 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -122,6 +122,19 @@ class RoomSelectViewTest : RobolectricTest() { ) onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() } + + @Test + fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + hasRoomListError = true, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_something_went_wrong)).assertIsDisplayed() + onNodeWithText(activity!!.getString(R.string.screen_room_select_error_cannot_load_rooms)).assertIsDisplayed() + onNodeWithText("Room with alias").assertDoesNotExist() + } } private fun AndroidComposeUiTest.setRoomSelectView( diff --git a/tools/localazy/config.json b/tools/localazy/config.json index e7f5f1e036..a46ae3adc1 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -14,6 +14,12 @@ "screen_bug_report_.*" ] }, + { + "name" : ":libraries:roomselect:impl", + "includeRegex" : [ + "screen\\.room_select\\..*" + ] + }, { "name" : ":features:rageshake:api", "includeRegex" : [