From e8ca16b74fefed6e81cd3473c9f41ad0910a5d4f Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:59:01 +0300 Subject: [PATCH 1/2] Ignore a room list filter that is incompatible with an already selected one Some room list filters are mutually exclusive, and the UI expresses that by hiding the incompatible chips rather than disabling them. Nothing enforced the rule on the write path, so two chips tapped in the same frame, before the list could recompose, both applied. The room list then matched nothing while the offending chip was hidden, leaving no way to see or undo what was filtering the rooms. DefaultFilterSelectionStrategy.select() now ignores a filter that is incompatible with one already selected, so the same table buildFilters() uses to hide chips also governs the selection itself. The currently applied filters win and the late tap is dropped, which matches the existing design where an incompatible chip is already on its way out of the UI when it is tapped. toggle(), deselect(), clear() and the FilterSelectionStrategy interface are unchanged. Relates to https://github.com/element-hq/element-x-android/issues/5383 --- .../DefaultFilterSelectionStrategy.kt | 5 +++ .../filters/RoomListFiltersPresenterTest.kt | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt index 847dbc2c39..4121ff9ab4 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt @@ -22,6 +22,11 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy { override val filterSelectionStates = MutableStateFlow(buildFilters()) override fun select(filter: RoomListFilter) { + // Incompatible filters are hidden from the UI rather than disabled, so a filter can still be + // selected while its chip is on its way out, typically when two chips are tapped in the same + // frame. Ignore it, the currently applied filters win. + // See https://github.com/element-hq/element-x-android/issues/5383 + if (selectedFilters.any { it in filter.incompatibleFilters }) return selectedFilters.add(filter) filterSelectionStates.value = buildFilters() } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt index 9fb87c0eec..ae1d7b5083 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt @@ -72,6 +72,51 @@ class RoomListFiltersPresenterTest { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest { + val presenter = createRoomListFiltersPresenter() + presenter.test { + awaitItem().let { state -> + // Both events are sent on the same state, i.e. before any recomposition, which is what + // happens when the two chips are tapped simultaneously. + state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites)) + state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites)) + } + advanceUntilIdle() + awaitLastSequentialItem().let { state -> + assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites) + assertThat(state.filterSelectionStates).containsExactly( + filterSelectionState(RoomListFilter.Favourites, true), + filterSelectionState(RoomListFilter.Unread, false), + filterSelectionState(RoomListFilter.People, false), + filterSelectionState(RoomListFilter.Rooms, false), + ).inOrder() + } + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest { + val presenter = createRoomListFiltersPresenter() + presenter.test { + awaitItem().let { state -> + state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People)) + state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) + } + advanceUntilIdle() + awaitLastSequentialItem().let { state -> + assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People) + assertThat(state.filterSelectionStates).containsExactly( + filterSelectionState(RoomListFilter.People, true), + filterSelectionState(RoomListFilter.Unread, false), + filterSelectionState(RoomListFilter.Favourites, false), + ).inOrder() + } + } + } + @Test @OptIn(ExperimentalCoroutinesApi::class) fun `present - clear filters event`() = runTest { From 8943c4e72418a561503971c0c1cf0e06377c8e67 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:46:13 +0300 Subject: [PATCH 2/2] Drop the comments from the filter selection guard Review feedback: the guard and the test name already say what the check is for, so the narration was redundant. --- .../impl/filters/selection/DefaultFilterSelectionStrategy.kt | 4 ---- .../home/impl/filters/RoomListFiltersPresenterTest.kt | 2 -- 2 files changed, 6 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt index 4121ff9ab4..9b5951ae3d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt @@ -22,10 +22,6 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy { override val filterSelectionStates = MutableStateFlow(buildFilters()) override fun select(filter: RoomListFilter) { - // Incompatible filters are hidden from the UI rather than disabled, so a filter can still be - // selected while its chip is on its way out, typically when two chips are tapped in the same - // frame. Ignore it, the currently applied filters win. - // See https://github.com/element-hq/element-x-android/issues/5383 if (selectedFilters.any { it in filter.incompatibleFilters }) return selectedFilters.add(filter) filterSelectionStates.value = buildFilters() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt index ae1d7b5083..02d947613f 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt @@ -78,8 +78,6 @@ class RoomListFiltersPresenterTest { val presenter = createRoomListFiltersPresenter() presenter.test { awaitItem().let { state -> - // Both events are sent on the same state, i.e. before any recomposition, which is what - // happens when the two chips are tapped simultaneously. state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites)) state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites)) }