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
This commit is contained in:
hayaksi1
2026-08-09 01:59:01 +03:00
parent 044737a3a7
commit e8ca16b74f
2 changed files with 50 additions and 0 deletions
@@ -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()
}
@@ -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 {