Merge pull request #6136 from element-hq/feature/fga/space_room_list_filter
Add Space Filters feature for Room List
This commit is contained in:
commit
6327641469
75 changed files with 1329 additions and 182 deletions
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.home.impl
|
||||
|
||||
import io.element.android.features.home.impl.roomlist.RoomListState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
|
|
@ -31,6 +32,7 @@ data class HomeState(
|
|||
val directLogoutState: DirectLogoutState,
|
||||
val eventSink: (HomeEvent) -> Unit,
|
||||
) {
|
||||
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
|
||||
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
|
||||
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
|
||||
val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty()
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
|
|||
import io.element.android.features.home.impl.roomlist.RoomListEvent
|
||||
import io.element.android.features.home.impl.roomlist.RoomListState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchView
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersView
|
||||
import io.element.android.features.home.impl.spaces.HomeSpacesView
|
||||
import io.element.android.libraries.androidutils.throttler.FirstThrottler
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -153,10 +156,15 @@ private fun HomeScaffold(
|
|||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
val roomListState: RoomListState = state.roomListState
|
||||
|
||||
BackHandler(
|
||||
enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats,
|
||||
) {
|
||||
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
|
||||
BackHandler(enabled = state.isBackHandlerEnabled) {
|
||||
if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
|
||||
state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
|
||||
} else {
|
||||
val spaceFiltersState = state.roomListState.spaceFiltersState
|
||||
if (spaceFiltersState is SpaceFiltersState.Selected) {
|
||||
spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = rememberHazeState()
|
||||
|
|
@ -168,7 +176,6 @@ private fun HomeScaffold(
|
|||
topBar = {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = state.currentHomeNavigationBarItem,
|
||||
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
|
||||
currentUserAndNeighbors = state.currentUserAndNeighbors,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
|
||||
|
|
@ -182,6 +189,7 @@ private fun HomeScaffold(
|
|||
scrollBehavior = scrollBehavior,
|
||||
displayFilters = state.displayRoomListFilters,
|
||||
filtersState = roomListState.filtersState,
|
||||
spaceFiltersState = roomListState.spaceFiltersState,
|
||||
canCreateSpaces = state.homeSpacesState.canCreateSpaces,
|
||||
canReportBug = state.canReportBug,
|
||||
modifier = Modifier.hazeEffect(
|
||||
|
|
@ -227,6 +235,7 @@ private fun HomeScaffold(
|
|||
RoomListContentView(
|
||||
contentState = roomListState.contentState,
|
||||
filtersState = roomListState.filtersState,
|
||||
spaceFiltersState = roomListState.spaceFiltersState,
|
||||
lazyListState = roomsLazyListState,
|
||||
hideInvitesAvatars = roomListState.hideInvitesAvatars,
|
||||
eventSink = roomListState.eventSink,
|
||||
|
|
@ -256,6 +265,7 @@ private fun HomeScaffold(
|
|||
.consumeWindowInsets(padding)
|
||||
.hazeSource(state = hazeState)
|
||||
)
|
||||
SpaceFiltersView(roomListState.spaceFiltersState)
|
||||
}
|
||||
HomeNavigationBarItem.Spaces -> {
|
||||
HomeSpacesView(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
|||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
|
|
@ -44,6 +45,10 @@ import io.element.android.features.home.impl.R
|
|||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersView
|
||||
import io.element.android.features.home.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.aSelectedSpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -75,7 +80,6 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
@Composable
|
||||
fun HomeTopBar(
|
||||
selectedNavigationItem: HomeNavigationBarItem,
|
||||
title: String,
|
||||
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
showAvatarIndicator: Boolean,
|
||||
areSearchResultsDisplayed: Boolean,
|
||||
|
|
@ -89,6 +93,7 @@ fun HomeTopBar(
|
|||
canReportBug: Boolean,
|
||||
displayFilters: Boolean,
|
||||
filtersState: RoomListFiltersState,
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
|
|
@ -103,12 +108,21 @@ fun HomeTopBar(
|
|||
scrolledContainerColor = Color.Transparent,
|
||||
),
|
||||
title = {
|
||||
val displayTitle = when (selectedNavigationItem) {
|
||||
HomeNavigationBarItem.Chats -> {
|
||||
when (spaceFiltersState) {
|
||||
is SpaceFiltersState.Selected -> spaceFiltersState.selectedFilter.spaceRoom.displayName
|
||||
else -> stringResource(selectedNavigationItem.labelRes)
|
||||
}
|
||||
}
|
||||
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
},
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
text = title,
|
||||
text = displayTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
|
|
@ -124,7 +138,8 @@ fun HomeTopBar(
|
|||
HomeNavigationBarItem.Chats -> RoomListMenuItems(
|
||||
onToggleSearch = onToggleSearch,
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
canReportBug = canReportBug
|
||||
canReportBug = canReportBug,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
)
|
||||
HomeNavigationBarItem.Spaces -> SpacesMenuItems(
|
||||
canCreateSpaces = canCreateSpaces,
|
||||
|
|
@ -154,6 +169,7 @@ private fun RoomListMenuItems(
|
|||
onToggleSearch: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
canReportBug: Boolean,
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onToggleSearch,
|
||||
|
|
@ -163,6 +179,7 @@ private fun RoomListMenuItems(
|
|||
contentDescription = stringResource(CommonStrings.action_search),
|
||||
)
|
||||
}
|
||||
SpaceFilterButton(spaceFiltersState = spaceFiltersState)
|
||||
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(
|
||||
|
|
@ -228,6 +245,38 @@ private fun SpacesMenuItems(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceFilterButton(
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
) {
|
||||
if (spaceFiltersState == SpaceFiltersState.Disabled) return
|
||||
|
||||
fun onClick() {
|
||||
when (spaceFiltersState) {
|
||||
is SpaceFiltersState.Unselected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
is SpaceFiltersState.Selected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val isSelected = spaceFiltersState is SpaceFiltersState.Selected
|
||||
IconButton(
|
||||
onClick = ::onClick,
|
||||
colors = if (isSelected) {
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.bgAccentRest,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.iconButtonColors()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Filter(),
|
||||
contentDescription = stringResource(R.string.screen_roomlist_your_spaces),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigationIcon(
|
||||
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
|
|
@ -309,7 +358,6 @@ private fun AccountIcon(
|
|||
internal fun HomeTopBarPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
|
|
@ -322,6 +370,30 @@ internal fun HomeTopBarPreview() = ElementPreview {
|
|||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onToggleSearch = {},
|
||||
onCreateSpace = {},
|
||||
canCreateSpaces = true,
|
||||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = aSelectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -332,7 +404,6 @@ internal fun HomeTopBarPreview() = ElementPreview {
|
|||
internal fun HomeTopBarSpacesPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Spaces,
|
||||
title = stringResource(R.string.screen_home_tab_spaces),
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
|
|
@ -345,6 +416,7 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
|
|||
canReportBug = true,
|
||||
displayFilters = false,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -355,7 +427,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
|
|||
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = true,
|
||||
areSearchResultsDisplayed = false,
|
||||
|
|
@ -368,6 +439,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
|
|||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -378,7 +450,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
|
|||
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
|
|
@ -391,6 +462,7 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
|
|||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContentState
|
|||
import io.element.android.features.home.impl.roomlist.RoomListContentStateProvider
|
||||
import io.element.android.features.home.impl.roomlist.RoomListEvent
|
||||
import io.element.android.features.home.impl.roomlist.SecurityBannerState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
|
|
@ -59,6 +61,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
fun RoomListContentView(
|
||||
contentState: RoomListContentState,
|
||||
filtersState: RoomListFiltersState,
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
lazyListState: LazyListState,
|
||||
hideInvitesAvatars: Boolean,
|
||||
eventSink: (RoomListEvent) -> Unit,
|
||||
|
|
@ -93,6 +96,7 @@ fun RoomListContentView(
|
|||
state = contentState,
|
||||
hideInvitesAvatars = hideInvitesAvatars,
|
||||
filtersState = filtersState,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
|
|
@ -172,6 +176,7 @@ private fun RoomsView(
|
|||
state: RoomListContentState.Rooms,
|
||||
hideInvitesAvatars: Boolean,
|
||||
filtersState: RoomListFiltersState,
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
eventSink: (RoomListEvent) -> Unit,
|
||||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
|
|
@ -180,9 +185,12 @@ private fun RoomsView(
|
|||
lazyListState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
||||
val isSpaceFilterSelected = spaceFiltersState is SpaceFiltersState.Selected
|
||||
val hasAnyFilterSelected = filtersState.hasAnyFilterSelected || isSpaceFilterSelected
|
||||
if (state.summaries.isEmpty() && hasAnyFilterSelected) {
|
||||
EmptyViewForFilterStates(
|
||||
selectedFilters = filtersState.selectedFilters(),
|
||||
isSpaceFilterSelected = isSpaceFilterSelected,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
|
|
@ -278,9 +286,10 @@ private fun RoomsViewList(
|
|||
@Composable
|
||||
private fun EmptyViewForFilterStates(
|
||||
selectedFilters: ImmutableList<RoomListFilter>,
|
||||
isSpaceFilterSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return
|
||||
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
|
||||
EmptyScaffold(
|
||||
title = emptyStateResources.title,
|
||||
subtitle = emptyStateResources.subtitle,
|
||||
|
|
@ -331,6 +340,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
|||
)
|
||||
}
|
||||
),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
hideInvitesAvatars = false,
|
||||
eventSink = {},
|
||||
onSetUpRecoveryClick = {},
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import io.element.android.features.home.impl.roomlist.RoomListPresenter
|
|||
import io.element.android.features.home.impl.roomlist.RoomListState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchPresenter
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
|
|
@ -31,4 +33,7 @@ interface RoomListModule {
|
|||
|
||||
@Binds
|
||||
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
|
||||
|
||||
@Binds
|
||||
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter<SpaceFiltersState>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.home.impl.filters
|
||||
|
||||
import io.element.android.features.home.impl.R
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
|
||||
|
||||
/**
|
||||
* Enum class representing the different filters that can be applied to the room list.
|
||||
|
|
@ -30,3 +31,13 @@ enum class RoomListFilter(val stringResource: Int) {
|
|||
Invites -> setOf(Rooms, People, Unread, Favourites)
|
||||
}
|
||||
}
|
||||
|
||||
fun RoomListFilter.into(): MatrixRoomListFilter {
|
||||
return when (this) {
|
||||
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
|
||||
RoomListFilter.People -> MatrixRoomListFilter.Category.People
|
||||
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
|
||||
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
|
||||
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ data class RoomListFiltersEmptyStateResources(
|
|||
/**
|
||||
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.
|
||||
*/
|
||||
fun fromSelectedFilters(selectedFilters: List<RoomListFilter>): RoomListFiltersEmptyStateResources? {
|
||||
fun fromSelectedFilters(selectedFilters: List<RoomListFilter>, isSpaceFilterSelected: Boolean): RoomListFiltersEmptyStateResources? {
|
||||
return when {
|
||||
isSpaceFilterSelected -> RoomListFiltersEmptyStateResources(
|
||||
title = R.string.screen_roomlist_filter_mixed_empty_state_title,
|
||||
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
|
||||
)
|
||||
selectedFilters.isEmpty() -> null
|
||||
selectedFilters.size == 1 -> {
|
||||
when (selectedFilters.first()) {
|
||||
|
|
|
|||
|
|
@ -9,24 +9,17 @@
|
|||
package io.element.android.features.home.impl.filters
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.home.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
|
||||
|
||||
@Inject
|
||||
class RoomListFiltersPresenter(
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val filterSelectionStrategy: FilterSelectionStrategy,
|
||||
) : Presenter<RoomListFiltersState> {
|
||||
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomListFiltersState {
|
||||
fun handleEvent(event: RoomListFiltersEvent) {
|
||||
|
|
@ -40,31 +33,9 @@ class RoomListFiltersPresenter(
|
|||
}
|
||||
}
|
||||
|
||||
val filters by produceState(initialValue = initialFilters) {
|
||||
filterSelectionStrategy.filterSelectionStates
|
||||
.map { filters ->
|
||||
value = filters.toImmutableList()
|
||||
filters.mapNotNull { filterState ->
|
||||
if (!filterState.isSelected) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
when (filterState.filter) {
|
||||
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
|
||||
RoomListFilter.People -> MatrixRoomListFilter.Category.People
|
||||
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
|
||||
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
|
||||
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
|
||||
}
|
||||
}
|
||||
}
|
||||
.collectLatest { filters ->
|
||||
val result = MatrixRoomListFilter.All(filters)
|
||||
roomListDataSource.updateFilter(result)
|
||||
}
|
||||
}
|
||||
|
||||
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
|
||||
return RoomListFiltersState(
|
||||
filterSelectionStates = filters,
|
||||
filterSelectionStates = filters.toImmutableList(),
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
|
||||
private val selectedFilters = LinkedHashSet<RoomListFilter>()
|
||||
private val availableFilters
|
||||
get() = RoomListFilter.entries.toSet()
|
||||
|
||||
override val filterSelectionStates = MutableStateFlow(buildFilters())
|
||||
|
||||
|
|
@ -45,7 +47,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
|
|||
isSelected = true
|
||||
)
|
||||
}
|
||||
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
|
||||
val unselectedFilters = availableFilters - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
|
||||
val unselectedFilterStates = unselectedFilters.map {
|
||||
FilterSelectionState(
|
||||
filter = it,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
|
||||
interface FilterSelectionStrategy {
|
||||
val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
|
||||
|
||||
fun select(filter: RoomListFilter)
|
||||
fun deselect(filter: RoomListFilter)
|
||||
fun isSelected(filter: RoomListFilter): Boolean
|
||||
|
|
|
|||
|
|
@ -28,9 +28,14 @@ import im.vector.app.features.analytics.plan.Interaction
|
|||
import io.element.android.features.announcement.api.Announcement
|
||||
import io.element.android.features.announcement.api.AnnouncementService
|
||||
import io.element.android.features.home.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.home.impl.filters.RoomListFilter.Rooms
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.filters.into
|
||||
import io.element.android.features.home.impl.search.RoomListSearchEvent
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.into
|
||||
import io.element.android.features.home.impl.spacefilters.selectedFilter
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
|
||||
|
|
@ -44,6 +49,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
|
|
@ -83,6 +89,7 @@ class RoomListPresenter(
|
|||
private val seenInvitesStore: SeenInvitesStore,
|
||||
private val announcementService: AnnouncementService,
|
||||
private val coldStartWatcher: AnalyticsColdStartWatcher,
|
||||
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService = client.encryptionService
|
||||
|
||||
|
|
@ -92,6 +99,7 @@ class RoomListPresenter(
|
|||
val leaveRoomState = leaveRoomPresenter.present()
|
||||
val filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
val spaceFiltersState = spaceFiltersPresenter.present()
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -150,6 +158,13 @@ class RoomListPresenter(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filtersState.filterSelectionStates, spaceFiltersState.selectedFilter()) {
|
||||
val selectedFilters = filtersState.selectedFilters().map { filter -> filter.into() }
|
||||
val selectedSpaceFilter = spaceFiltersState.selectedFilter().into()
|
||||
val allFilters = RoomListFilter.All(selectedFilters + listOfNotNull(selectedSpaceFilter))
|
||||
roomListDataSource.updateFilter(allFilters)
|
||||
}
|
||||
|
||||
val contentState = roomListContentState(
|
||||
securityBannerDismissed,
|
||||
showNewNotificationSoundBanner,
|
||||
|
|
@ -163,6 +178,7 @@ class RoomListPresenter(
|
|||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
hideInvitesAvatars = hideInvitesAvatar,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
|
|
@ -26,6 +27,7 @@ data class RoomListState(
|
|||
val leaveRoomState: LeaveRoomState,
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val spaceFiltersState: SpaceFiltersState,
|
||||
val contentState: RoomListContentState,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val hideInvitesAvatars: Boolean,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import io.element.android.features.home.impl.model.aRoomListRoomSummary
|
|||
import io.element.android.features.home.impl.model.anInviteSender
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.search.aRoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
|
|
@ -52,6 +54,7 @@ internal fun aRoomListState(
|
|||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
searchState: RoomListSearchState = aRoomListSearchState(),
|
||||
filtersState: RoomListFiltersState = aRoomListFiltersState(),
|
||||
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
contentState: RoomListContentState = aRoomsContentState(),
|
||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||
hideInvitesAvatars: Boolean = false,
|
||||
|
|
@ -63,6 +66,7 @@ internal fun aRoomListState(
|
|||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
hideInvitesAvatars = hideInvitesAvatars,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
|
||||
sealed interface SpaceFiltersEvent {
|
||||
// Only valid in Unselected state
|
||||
sealed interface Unselected : SpaceFiltersEvent {
|
||||
data object ShowFilters : Unselected
|
||||
}
|
||||
|
||||
// Only valid in Selecting state
|
||||
sealed interface Selecting : SpaceFiltersEvent {
|
||||
data object Cancel : Selecting
|
||||
data class SelectFilter(val spaceFilter: SpaceServiceFilter) : Selecting
|
||||
}
|
||||
|
||||
// Only valid in Selected state
|
||||
sealed interface Selected : SpaceFiltersEvent {
|
||||
data object ClearSelection : Selected
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Inject
|
||||
class SpaceFiltersPresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<SpaceFiltersState> {
|
||||
@Composable
|
||||
override fun present(): SpaceFiltersState {
|
||||
val isFeatureEnabled by featureFlagService
|
||||
.isFeatureEnabledFlow(FeatureFlags.RoomListSpaceFilters)
|
||||
.collectAsState(initial = false)
|
||||
|
||||
val availableFilters by remember {
|
||||
matrixClient.spaceService.spaceFiltersFlow.map { it.toImmutableList() }
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
if (!isFeatureEnabled || availableFilters.isEmpty()) {
|
||||
return SpaceFiltersState.Disabled
|
||||
}
|
||||
|
||||
var selectionMode by remember { mutableStateOf<SelectionMode>(SelectionMode.Unselected) }
|
||||
|
||||
fun handleUnselectedEvent(event: SpaceFiltersEvent.Unselected) {
|
||||
when (event) {
|
||||
SpaceFiltersEvent.Unselected.ShowFilters -> {
|
||||
selectionMode = SelectionMode.Selecting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectingEvent(event: SpaceFiltersEvent.Selecting) {
|
||||
when (event) {
|
||||
SpaceFiltersEvent.Selecting.Cancel -> {
|
||||
selectionMode = SelectionMode.Unselected
|
||||
}
|
||||
is SpaceFiltersEvent.Selecting.SelectFilter -> {
|
||||
selectionMode = SelectionMode.Selected(event.spaceFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectedEvent(event: SpaceFiltersEvent.Selected) {
|
||||
when (event) {
|
||||
SpaceFiltersEvent.Selected.ClearSelection -> {
|
||||
selectionMode = SelectionMode.Unselected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return when (val mode = selectionMode) {
|
||||
SelectionMode.Unselected -> SpaceFiltersState.Unselected(
|
||||
eventSink = ::handleUnselectedEvent,
|
||||
)
|
||||
SelectionMode.Selecting -> {
|
||||
val searchQuery = rememberTextFieldState()
|
||||
SpaceFiltersState.Selecting(
|
||||
availableFilters = availableFilters,
|
||||
searchQuery = searchQuery,
|
||||
eventSink = ::handleSelectingEvent,
|
||||
)
|
||||
}
|
||||
is SelectionMode.Selected -> {
|
||||
var selectedFilter by remember { mutableStateOf(mode.filter) }
|
||||
// Makes sure the selectedFilter stays in sync with the available filters
|
||||
LaunchedEffect(availableFilters) {
|
||||
val upToDateFilter = availableFilters
|
||||
.firstOrNull { it.spaceRoom.roomId == mode.filter.spaceRoom.roomId }
|
||||
if (upToDateFilter == null) {
|
||||
selectionMode = SelectionMode.Unselected
|
||||
} else {
|
||||
selectedFilter = upToDateFilter
|
||||
}
|
||||
}
|
||||
SpaceFiltersState.Selected(
|
||||
selectedFilter = selectedFilter,
|
||||
eventSink = ::handleSelectedEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface SelectionMode {
|
||||
data object Unselected : SelectionMode
|
||||
data object Selecting : SelectionMode
|
||||
data class Selected(val filter: SpaceServiceFilter) : SelectionMode
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Immutable
|
||||
sealed interface SpaceFiltersState {
|
||||
data object Disabled : SpaceFiltersState
|
||||
|
||||
data class Unselected(
|
||||
val eventSink: (SpaceFiltersEvent.Unselected) -> Unit,
|
||||
) : SpaceFiltersState
|
||||
|
||||
data class Selecting(
|
||||
val availableFilters: ImmutableList<SpaceServiceFilter>,
|
||||
val searchQuery: TextFieldState,
|
||||
val eventSink: (SpaceFiltersEvent.Selecting) -> Unit,
|
||||
) : SpaceFiltersState {
|
||||
val visibleFilters: ImmutableList<SpaceServiceFilter>
|
||||
get() {
|
||||
val query = searchQuery.text.toString()
|
||||
if (query.isBlank()) return availableFilters
|
||||
return availableFilters.filter { filter ->
|
||||
filter.spaceRoom.displayName.contains(query, ignoreCase = true) ||
|
||||
(filter.spaceRoom.canonicalAlias?.value ?: "").contains(query, ignoreCase = true)
|
||||
}.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
data class Selected(
|
||||
val selectedFilter: SpaceServiceFilter,
|
||||
val eventSink: (SpaceFiltersEvent.Selected) -> Unit,
|
||||
) : SpaceFiltersState
|
||||
}
|
||||
|
||||
fun SpaceFiltersState.selectedFilter(): SpaceServiceFilter? {
|
||||
return when (this) {
|
||||
is SpaceFiltersState.Selected -> this.selectedFilter
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun SpaceServiceFilter?.into(): RoomListFilter? {
|
||||
return this?.let { RoomListFilter.Identifiers(descendants) }
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class SpaceFiltersStateProvider : PreviewParameterProvider<SpaceFiltersState> {
|
||||
override val values: Sequence<SpaceFiltersState>
|
||||
get() = sequenceOf(
|
||||
aSelectingSpaceFiltersState(),
|
||||
aSelectingSpaceFiltersState(searchQuery = "Pr")
|
||||
)
|
||||
}
|
||||
|
||||
fun aDisabledSpaceFiltersState() = SpaceFiltersState.Disabled
|
||||
|
||||
fun anUnselectedSpaceFiltersState(
|
||||
eventSink: (SpaceFiltersEvent.Unselected) -> Unit = {},
|
||||
) = SpaceFiltersState.Unselected(
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aSelectingSpaceFiltersState(
|
||||
availableFilters: List<SpaceServiceFilter> = listOf(
|
||||
aSpaceServiceFilter(
|
||||
displayName = "Work",
|
||||
canonicalAlias = RoomAlias("#work:example.com"),
|
||||
),
|
||||
aSpaceServiceFilter(
|
||||
displayName = "Personal",
|
||||
roomId = RoomId("!personal:example.com"),
|
||||
),
|
||||
aSpaceServiceFilter(
|
||||
displayName = "Projects",
|
||||
roomId = RoomId("!projects:example.com"),
|
||||
canonicalAlias = RoomAlias("#projects:example.com"),
|
||||
level = 1,
|
||||
),
|
||||
aSpaceServiceFilter(
|
||||
displayName = "Gaming",
|
||||
roomId = RoomId("!gaming:example.com"),
|
||||
),
|
||||
),
|
||||
searchQuery: String = "",
|
||||
eventSink: (SpaceFiltersEvent.Selecting) -> Unit = {},
|
||||
) = SpaceFiltersState.Selecting(
|
||||
availableFilters = availableFilters.toImmutableList(),
|
||||
searchQuery = TextFieldState(searchQuery),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aSelectedSpaceFiltersState(
|
||||
selectedFilter: SpaceServiceFilter = aSpaceServiceFilter(displayName = "Work"),
|
||||
eventSink: (SpaceFiltersEvent.Selected) -> Unit = {},
|
||||
) = SpaceFiltersState.Selected(
|
||||
selectedFilter = selectedFilter,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aSpaceServiceFilter(
|
||||
displayName: String = "Space",
|
||||
roomId: RoomId = RoomId("!space:example.com"),
|
||||
canonicalAlias: RoomAlias? = null,
|
||||
level: Int = 0,
|
||||
descendants: List<RoomId> = emptyList(),
|
||||
) = SpaceServiceFilter(
|
||||
spaceRoom = aSpaceRoom(displayName = displayName, roomId = roomId, canonicalAlias = canonicalAlias),
|
||||
level = level,
|
||||
descendants = descendants,
|
||||
)
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.home.impl.R
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchField
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SpaceFiltersView(
|
||||
state: SpaceFiltersState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isSelecting by rememberUpdatedState(state is SpaceFiltersState.Selecting)
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
confirmValueChange = { sheetValueTarget ->
|
||||
// This ensures the hide animation is not cancelled
|
||||
when (sheetValueTarget) {
|
||||
SheetValue.Expanded -> isSelecting
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
)
|
||||
LaunchedEffect(isSelecting) {
|
||||
if (!isSelecting) {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
if (sheetState.isVisible || isSelecting) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier
|
||||
.systemBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = {
|
||||
if (state is SpaceFiltersState.Selecting) {
|
||||
state.eventSink(SpaceFiltersEvent.Selecting.Cancel)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f)
|
||||
) {
|
||||
if (state is SpaceFiltersState.Selecting) {
|
||||
SpaceFiltersBottomSheetContent(
|
||||
filters = state.visibleFilters,
|
||||
searchQuery = state.searchQuery,
|
||||
onFilterSelected = { filter ->
|
||||
state.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(filter))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceFiltersBottomSheetContent(
|
||||
filters: List<SpaceServiceFilter>,
|
||||
searchQuery: TextFieldState,
|
||||
onFilterSelected: (SpaceServiceFilter) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_roomlist_your_spaces),
|
||||
style = ElementTheme.typography.fontHeadingSmMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
SearchField(
|
||||
state = searchQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
placeholder = stringResource(CommonStrings.action_search),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LazyColumn {
|
||||
items(filters) { filter ->
|
||||
SpaceFilterItem(
|
||||
filter = filter,
|
||||
onClick = { onFilterSelected(filter) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceFilterItem(
|
||||
filter: SpaceServiceFilter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spaceRoom = filter.spaceRoom
|
||||
val supportingText = spaceRoom.canonicalAlias?.value
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Level-based indentation
|
||||
Spacer(modifier = Modifier.width((16 * filter.level).dp))
|
||||
Avatar(
|
||||
avatarData = spaceRoom.getAvatarData(AvatarSize.RoomSelectRoomListItem),
|
||||
avatarType = AvatarType.Space(),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = spaceRoom.displayName,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (supportingText != null) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview {
|
||||
SpaceFiltersView(state = state)
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ class HomeSpacesPresenter(
|
|||
val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
|
||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||
val spaceRooms by remember {
|
||||
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
|
||||
client.spaceService.topLevelSpacesFlow.map { it.toImmutableList() }
|
||||
}.collectAsState(persistentListOf())
|
||||
|
||||
val seenSpaceInvites by remember {
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return null when selectedFilters is empty`() {
|
||||
val selectedFilters = emptyList<RoomListFilter>()
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Unread)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
|
|
@ -32,7 +32,7 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.People)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
|
|
@ -41,7 +41,7 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Rooms)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
|
|
@ -50,7 +50,7 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Favourites)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
|
||||
|
|
@ -59,7 +59,7 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Invites)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
|
|
@ -68,7 +68,15 @@ class RoomListFiltersEmptyStateResourcesTest {
|
|||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when isSpaceFilterSelected is true`() {
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(emptyList(), isSpaceFilterSelected = true)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
|
|
|
|||
|
|
@ -9,23 +9,10 @@
|
|||
package io.element.android.features.home.impl.filters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.impl.FakeDateTimeObserver
|
||||
import io.element.android.features.home.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
|
||||
import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
|
||||
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
|
|
@ -54,8 +41,7 @@ class RoomListFiltersPresenterTest {
|
|||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - toggle rooms filter`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListFiltersPresenter(roomListService)
|
||||
val presenter = createRoomListFiltersPresenter()
|
||||
presenter.test {
|
||||
awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
|
||||
awaitLastSequentialItem().let { state ->
|
||||
|
|
@ -89,8 +75,7 @@ class RoomListFiltersPresenterTest {
|
|||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - clear filters event`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListFiltersPresenter(roomListService)
|
||||
val presenter = createRoomListFiltersPresenter()
|
||||
presenter.test {
|
||||
awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
|
||||
awaitLastSequentialItem().let { state ->
|
||||
|
|
@ -110,25 +95,8 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
|
|||
isSelected = selected,
|
||||
)
|
||||
|
||||
private fun TestScope.createRoomListFiltersPresenter(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
|
||||
): RoomListFiltersPresenter {
|
||||
private fun TestScope.createRoomListFiltersPresenter(): RoomListFiltersPresenter {
|
||||
return RoomListFiltersPresenter(
|
||||
roomListDataSource = RoomListDataSource(
|
||||
roomListService = roomListService,
|
||||
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
|
||||
dateFormatter = dateFormatter,
|
||||
roomLatestEventFormatter = roomLatestEventFormatter,
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
dateTimeObserver = FakeDateTimeObserver(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
),
|
||||
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import io.element.android.features.home.impl.model.createRoomListRoomSummary
|
|||
import io.element.android.features.home.impl.search.RoomListSearchEvent
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.search.aRoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
|
||||
|
|
@ -660,6 +662,7 @@ class RoomListPresenterTest {
|
|||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
spaceFiltersPresenter: Presenter<SpaceFiltersState> = Presenter { aDisabledSpaceFiltersState() },
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
|
|
@ -683,6 +686,7 @@ class RoomListPresenterTest {
|
|||
searchPresenter = searchPresenter,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
filtersPresenter = filtersPresenter,
|
||||
spaceFiltersPresenter = spaceFiltersPresenter,
|
||||
analyticsService = analyticsService,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SpaceFiltersPresenterTest {
|
||||
@Test
|
||||
fun `present - when feature flag is disabled returns Disabled state`() = runTest {
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to false)
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state).isEqualTo(SpaceFiltersState.Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when available filters is empty returns Disabled state`() = runTest {
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state).isEqualTo(SpaceFiltersState.Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when feature flag is enabled and filters exist returns Unselected state`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter))
|
||||
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state).isInstanceOf(SpaceFiltersState.Unselected::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ShowFilters event transitions from Unselected to Selecting`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters first
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter))
|
||||
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
val selectingState = awaitLastSequentialItem()
|
||||
assertThat(selectingState).isInstanceOf(SpaceFiltersState.Selecting::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Cancel event in Selecting state transitions back to Unselected`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters first
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter))
|
||||
|
||||
// Start in Unselected
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Now in Selecting
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
selectingState.eventSink(SpaceFiltersEvent.Selecting.Cancel)
|
||||
|
||||
// Back to Unselected
|
||||
val finalState = awaitLastSequentialItem()
|
||||
assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SelectFilter event in Selecting state transitions to Selected`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters first
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter))
|
||||
|
||||
// Start in Unselected
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Now in Selecting
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
|
||||
|
||||
// Now in Selected
|
||||
val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
|
||||
assertThat(selectedState.selectedFilter).isEqualTo(spaceFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ClearSelection event in Selected state transitions back to Unselected`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters first
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter))
|
||||
|
||||
// Start in Unselected
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Now in Selecting
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
|
||||
|
||||
// Now in Selected
|
||||
val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
|
||||
selectedState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
|
||||
|
||||
// Back to Unselected
|
||||
val finalState = awaitLastSequentialItem()
|
||||
assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - available filters are passed from SpaceService`() = runTest {
|
||||
val spaceFilter1 = aSpaceServiceFilter(displayName = "Work", roomId = RoomId("!work:example.com"))
|
||||
val spaceFilter2 = aSpaceServiceFilter(displayName = "Personal", roomId = RoomId("!personal:example.com"))
|
||||
val spaceFilters = listOf(spaceFilter1, spaceFilter2)
|
||||
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit space filters
|
||||
spaceService.emitSpaceFilters(spaceFilters)
|
||||
|
||||
// Start in Unselected
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Now in Selecting with available filters
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
assertThat(selectingState.availableFilters).containsExactly(spaceFilter1, spaceFilter2).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - selected filter is cleared when space is removed from available filters`() = runTest {
|
||||
val spaceFilter = aSpaceServiceFilter(displayName = "Work", roomId = RoomId("!work:example.com"))
|
||||
val otherSpaceFilter = aSpaceServiceFilter(displayName = "Personal", roomId = RoomId("!personal:example.com"))
|
||||
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit filters first
|
||||
spaceService.emitSpaceFilters(listOf(spaceFilter, otherSpaceFilter))
|
||||
|
||||
// Go to Selecting
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Select the filter
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
|
||||
|
||||
// Verify in Selected state
|
||||
val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
|
||||
assertThat(selectedState.selectedFilter).isEqualTo(spaceFilter)
|
||||
|
||||
// Remove the selected space from available filters (but keep other spaces)
|
||||
spaceService.emitSpaceFilters(listOf(otherSpaceFilter))
|
||||
|
||||
// Should auto-transition to Unselected
|
||||
val finalState = awaitLastSequentialItem()
|
||||
assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - selected filter stays in sync when available filters update`() = runTest {
|
||||
val originalFilter = aSpaceServiceFilter(
|
||||
displayName = "Work",
|
||||
roomId = RoomId("!work:example.com"),
|
||||
descendants = listOf(RoomId("!room1:example.com"))
|
||||
)
|
||||
val updatedFilter = aSpaceServiceFilter(
|
||||
displayName = "Work",
|
||||
roomId = RoomId("!work:example.com"),
|
||||
descendants = listOf(RoomId("!room1:example.com"), RoomId("!room2:example.com"))
|
||||
)
|
||||
|
||||
val spaceService = FakeSpaceService()
|
||||
val matrixClient = FakeMatrixClient(spaceService = spaceService)
|
||||
|
||||
val presenter = createSpaceFiltersPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
// Emit initial space filters
|
||||
spaceService.emitSpaceFilters(listOf(originalFilter))
|
||||
|
||||
// Start in Unselected
|
||||
val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
|
||||
unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
|
||||
|
||||
// Now in Selecting
|
||||
val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
|
||||
selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(originalFilter))
|
||||
|
||||
// Now in Selected
|
||||
val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
|
||||
assertThat(selectedState.selectedFilter.descendants).hasSize(1)
|
||||
|
||||
// Emit updated space filters
|
||||
spaceService.emitSpaceFilters(listOf(updatedFilter))
|
||||
|
||||
// Selected filter should be updated
|
||||
val updatedSelectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
|
||||
assertThat(updatedSelectedState.selectedFilter.descendants).hasSize(2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSpaceFiltersPresenter(
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
): SpaceFiltersPresenter {
|
||||
return SpaceFiltersPresenter(
|
||||
featureFlagService = featureFlagService,
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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.features.home.impl.spacefilters
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SpaceFiltersViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on a filter with alias shows display name and alias`() {
|
||||
val filter = aSpaceServiceFilter(
|
||||
displayName = "Test Space",
|
||||
canonicalAlias = A_ROOM_ALIAS,
|
||||
)
|
||||
val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>()
|
||||
rule.setSpaceFiltersView(
|
||||
state = aSelectingSpaceFiltersState(
|
||||
availableFilters = listOf(filter),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
|
||||
// Both display name and alias should be visible
|
||||
rule.onNodeWithText(filter.spaceRoom.displayName).assertExists()
|
||||
rule.onNodeWithText(A_ROOM_ALIAS.value).assertExists()
|
||||
|
||||
rule.onNodeWithText(filter.spaceRoom.displayName).performClick()
|
||||
|
||||
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple filters are displayed and clickable`() {
|
||||
val filter1 = aSpaceServiceFilter(displayName = "Space One")
|
||||
val filter2 = aSpaceServiceFilter(displayName = "Space Two")
|
||||
val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>()
|
||||
rule.setSpaceFiltersView(
|
||||
state = aSelectingSpaceFiltersState(
|
||||
availableFilters = listOf(filter1, filter2),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
|
||||
// Both filters should be visible
|
||||
rule.onNodeWithText(filter1.spaceRoom.displayName).assertExists()
|
||||
rule.onNodeWithText(filter2.spaceRoom.displayName).assertExists()
|
||||
|
||||
// Click on second filter
|
||||
rule.onNodeWithText(filter2.spaceRoom.displayName).performClick()
|
||||
|
||||
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceFiltersView(
|
||||
state: SpaceFiltersState,
|
||||
) {
|
||||
setContent {
|
||||
SpaceFiltersView(state = state)
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,13 @@ enum class FeatureFlags(
|
|||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
RoomListSpaceFilters(
|
||||
key = "feature.roomListSpaceFilters",
|
||||
title = "Room list space filters",
|
||||
description = "Allow filtering the room list by space.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
PrintLogsToLogcat(
|
||||
key = "feature.print_logs_to_logcat",
|
||||
title = "Print logs to logcat",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface RoomListFilter {
|
||||
companion object {
|
||||
/**
|
||||
|
|
@ -41,6 +43,10 @@ sealed interface RoomListFilter {
|
|||
val filters: List<RoomListFilter>
|
||||
) : RoomListFilter
|
||||
|
||||
data class Identifiers(
|
||||
val values: List<RoomId>,
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are unread.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
interface SpaceService {
|
||||
val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
|
||||
suspend fun joinedSpaces(): Result<List<SpaceRoom>>
|
||||
|
||||
val topLevelSpacesFlow: SharedFlow<List<SpaceRoom>>
|
||||
val spaceFiltersFlow: SharedFlow<List<SpaceServiceFilter>>
|
||||
suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>>
|
||||
|
||||
suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector 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.matrix.api.spaces
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
/**
|
||||
* Represents a space filter for filtering rooms by space membership.
|
||||
*
|
||||
* @property spaceRoom The space room associated with this filter.
|
||||
* @property level The nesting level of the space (0 = top level, 1 = first level child, etc.).
|
||||
* @property descendants The list of room IDs that are descendants of this space.
|
||||
*/
|
||||
data class SpaceServiceFilter(
|
||||
val spaceRoom: SpaceRoom,
|
||||
val level: Int,
|
||||
val descendants: List<RoomId>,
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any
|
|||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Identifiers
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
|
||||
|
|
@ -60,6 +61,7 @@ internal object RoomListFilterMapper {
|
|||
return when (filter) {
|
||||
is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) })
|
||||
is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) })
|
||||
is RoomListFilter.Identifiers -> Identifiers(identifiers = filter.values.map { it.value })
|
||||
RoomListFilter.None -> None
|
||||
RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP)
|
||||
RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
|
|||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
|
@ -31,9 +32,11 @@ import kotlinx.coroutines.flow.catch
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.SpaceFilterUpdate
|
||||
import org.matrix.rustcomponents.sdk.SpaceListUpdate
|
||||
import org.matrix.rustcomponents.sdk.SpaceServiceInterface
|
||||
import org.matrix.rustcomponents.sdk.SpaceServiceJoinedSpacesListener
|
||||
import org.matrix.rustcomponents.sdk.SpaceServiceSpaceFiltersListener
|
||||
import timber.log.Timber
|
||||
import org.matrix.rustcomponents.sdk.SpaceService as ClientSpaceService
|
||||
|
||||
|
|
@ -45,20 +48,20 @@ class RustSpaceService(
|
|||
private val analyticsService: AnalyticsService,
|
||||
) : SpaceService {
|
||||
private val spaceRoomMapper = SpaceRoomMapper()
|
||||
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
|
||||
private val spaceFilterMapper = SpaceServiceFilterMapper(spaceRoomMapper)
|
||||
|
||||
override val topLevelSpacesFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
|
||||
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
|
||||
spaceRoomsFlow = spaceRoomsFlow,
|
||||
spaceRoomsFlow = topLevelSpacesFlow,
|
||||
mapper = spaceRoomMapper,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
|
||||
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerSpaceService
|
||||
.topLevelJoinedSpaces()
|
||||
.map(spaceRoomMapper::map)
|
||||
}
|
||||
}
|
||||
override val spaceFiltersFlow = MutableSharedFlow<List<SpaceServiceFilter>>(replay = 1, extraBufferCapacity = 1)
|
||||
private val spaceFilterUpdateProcessor = SpaceServiceFilterUpdateProcessor(
|
||||
spaceFiltersFlow = spaceFiltersFlow,
|
||||
mapper = spaceFilterMapper,
|
||||
)
|
||||
|
||||
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
|
|
@ -123,6 +126,13 @@ class RustSpaceService(
|
|||
spaceListUpdateProcessor.postUpdates(updates)
|
||||
}
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
||||
innerSpaceService
|
||||
.spaceFilterListUpdate()
|
||||
.onEach { updates ->
|
||||
spaceFilterUpdateProcessor.postUpdates(updates)
|
||||
}
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,3 +152,20 @@ internal fun SpaceServiceInterface.spaceListUpdate(): Flow<List<SpaceListUpdate>
|
|||
}.catch {
|
||||
Timber.d(it, "spaceDiffFlow() failed")
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal fun SpaceServiceInterface.spaceFilterListUpdate(): Flow<List<SpaceFilterUpdate>> =
|
||||
callbackFlow {
|
||||
val listener = object : SpaceServiceSpaceFiltersListener {
|
||||
override fun onUpdate(filterUpdates: List<SpaceFilterUpdate>) {
|
||||
trySendBlocking(filterUpdates)
|
||||
}
|
||||
}
|
||||
Timber.d("Open spaceFilterDiffFlow for SpaceServiceInterface ${this@spaceFilterListUpdate}")
|
||||
val taskHandle = subscribeToSpaceFilters(listener)
|
||||
awaitClose {
|
||||
Timber.d("Close spaceFilterDiffFlow for SpaceServiceInterface ${this@spaceFilterListUpdate}")
|
||||
taskHandle.cancelAndDestroy()
|
||||
}
|
||||
}.catch {
|
||||
Timber.d(it, "spaceFilterListUpdate() failed")
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector 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.matrix.impl.spaces
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import org.matrix.rustcomponents.sdk.SpaceFilter as RustSpaceFilter
|
||||
|
||||
class SpaceServiceFilterMapper(
|
||||
private val spaceRoomMapper: SpaceRoomMapper,
|
||||
) {
|
||||
fun map(spaceFilter: RustSpaceFilter): SpaceServiceFilter {
|
||||
return SpaceServiceFilter(
|
||||
spaceRoom = spaceRoomMapper.map(spaceFilter.spaceRoom),
|
||||
level = spaceFilter.level.toInt(),
|
||||
descendants = spaceFilter.descendants.map { RoomId(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector 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.matrix.impl.spaces
|
||||
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.matrix.rustcomponents.sdk.SpaceFilterUpdate
|
||||
import timber.log.Timber
|
||||
|
||||
internal class SpaceServiceFilterUpdateProcessor(
|
||||
private val spaceFiltersFlow: MutableSharedFlow<List<SpaceServiceFilter>>,
|
||||
private val mapper: SpaceServiceFilterMapper,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun postUpdates(updates: List<SpaceFilterUpdate>) {
|
||||
Timber.v("Update space filters from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
|
||||
updateSpaceFilters {
|
||||
updates.forEach { update -> applyUpdate(update) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateSpaceFilters(block: MutableList<SpaceServiceFilter>.() -> Unit) =
|
||||
mutex.withLock {
|
||||
val spaceFilters = if (spaceFiltersFlow.replayCache.isNotEmpty()) {
|
||||
spaceFiltersFlow.first().toMutableList()
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
block(spaceFilters)
|
||||
spaceFiltersFlow.emit(spaceFilters)
|
||||
}
|
||||
|
||||
private fun MutableList<SpaceServiceFilter>.applyUpdate(update: SpaceFilterUpdate) {
|
||||
when (update) {
|
||||
is SpaceFilterUpdate.Append -> {
|
||||
val newFilters = update.values.map(mapper::map)
|
||||
addAll(newFilters)
|
||||
}
|
||||
SpaceFilterUpdate.Clear -> clear()
|
||||
is SpaceFilterUpdate.Insert -> {
|
||||
val newFilter = mapper.map(update.value)
|
||||
add(update.index.toInt(), newFilter)
|
||||
}
|
||||
SpaceFilterUpdate.PopBack -> {
|
||||
removeAt(lastIndex)
|
||||
}
|
||||
SpaceFilterUpdate.PopFront -> {
|
||||
removeAt(0)
|
||||
}
|
||||
is SpaceFilterUpdate.PushBack -> {
|
||||
val newFilter = mapper.map(update.value)
|
||||
add(newFilter)
|
||||
}
|
||||
is SpaceFilterUpdate.PushFront -> {
|
||||
val newFilter = mapper.map(update.value)
|
||||
add(0, newFilter)
|
||||
}
|
||||
is SpaceFilterUpdate.Remove -> {
|
||||
removeAt(update.index.toInt())
|
||||
}
|
||||
is SpaceFilterUpdate.Reset -> {
|
||||
clear()
|
||||
val newFilters = update.values.map(mapper::map)
|
||||
addAll(newFilters)
|
||||
}
|
||||
is SpaceFilterUpdate.Set -> {
|
||||
val newFilter = mapper.map(update.value)
|
||||
this[update.index.toInt()] = newFilter
|
||||
}
|
||||
is SpaceFilterUpdate.Truncate -> {
|
||||
subList(update.length.toInt(), size).clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
|
|||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
|
@ -20,7 +21,6 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
class FakeSpaceService(
|
||||
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
|
||||
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
|
||||
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
|
||||
private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
|
|
@ -29,16 +29,20 @@ class FakeSpaceService(
|
|||
private val editableSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
|
||||
private val addChildToSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
) : SpaceService {
|
||||
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
|
||||
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
|
||||
get() = _spaceRoomsFlow.asSharedFlow()
|
||||
private val _topLevelSpacesFlow = MutableSharedFlow<List<SpaceRoom>>()
|
||||
override val topLevelSpacesFlow: SharedFlow<List<SpaceRoom>>
|
||||
get() = _topLevelSpacesFlow.asSharedFlow()
|
||||
|
||||
suspend fun emitSpaceRoomList(value: List<SpaceRoom>) {
|
||||
_spaceRoomsFlow.emit(value)
|
||||
suspend fun emitTopLevelSpaces(value: List<SpaceRoom>) {
|
||||
_topLevelSpacesFlow.emit(value)
|
||||
}
|
||||
|
||||
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = simulateLongTask {
|
||||
return joinedSpacesResult()
|
||||
private val _spaceFiltersFlow = MutableSharedFlow<List<SpaceServiceFilter>>()
|
||||
override val spaceFiltersFlow: SharedFlow<List<SpaceServiceFilter>>
|
||||
get() = _spaceFiltersFlow.asSharedFlow()
|
||||
|
||||
suspend fun emitSpaceFilters(value: List<SpaceServiceFilter>) {
|
||||
_spaceFiltersFlow.emit(value)
|
||||
}
|
||||
|
||||
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ class KonsistPreviewTest {
|
|||
"GradientFloatingActionButtonCircleShapePreview",
|
||||
"HeaderFooterPageScrollablePreview",
|
||||
"HomeTopBarMultiAccountPreview",
|
||||
"HomeTopBarSpaceFiltersSelectedPreview",
|
||||
"HomeTopBarSpacesPreview",
|
||||
"HomeTopBarWithIndicatorPreview",
|
||||
"IconsOtherPreview",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6992a2fbaebed88e22c58e393d086bbcba50afd822ac869b9e27dc68ed3a493e
|
||||
size 22007
|
||||
oid sha256:d03c47b707264a6aad1ff7f75419eee07a379c51cbcb07ac504d48983b362225
|
||||
size 22182
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:67a87f2231268e26e06e62aada18c4c62e8ee53e1eb1ddc538f8c7e98881acbc
|
||||
size 20256
|
||||
oid sha256:af5561d67d9d28418c1f9d106527c674ffbd63aacc793e371942e9216d2aadc8
|
||||
size 20425
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:601cff975cb714b8fafdbee6741f6f642ea744880b31bf060540f821fc5f911e
|
||||
size 23195
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8722652998d147e103dfb84934787b406c93508aa6a13815b82f36a424e02863
|
||||
size 21316
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0fdbe7e7ab22f44cc60a04582d5a5159055d4af3af0d3d2a7cd012f48abd9592
|
||||
size 22443
|
||||
oid sha256:76867d38b4b78d36837bbafc93c442b714fc1f0941a1cbe0cf46f7befb4f5347
|
||||
size 22611
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96942af60064898a853268f8aa8103f04a836332fef09fbfa83ad01cccae54d3
|
||||
size 20651
|
||||
oid sha256:1fb40adfd3139dd952b9707825e4938d630dba7f00d3192b3aef300de90ca0ba
|
||||
size 20823
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e38f16c942bcfb31103b5e80d168a5425f181632ea4440a054aa4d0985bd335a
|
||||
size 22106
|
||||
oid sha256:a03be8035fc5e5a4a653744504389d9bd2c9f35e1ab9a4070d73dbb08f934e4d
|
||||
size 22277
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:677c21f745e4b9c7984f6c8fe3f84a27fabe6aeaf5f8cac82bb5ac692bd6a797
|
||||
size 20308
|
||||
oid sha256:67f04b8baa89527233d9cfbe0faf6e57b2ad384d35935cf866e46cf3d05f0fc7
|
||||
size 20474
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5ed03e5c6103dd4d96a0c4a8fda97808801f853cc3e05595e339a67e0b228027
|
||||
size 30472
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfeca128a35edebe19f59749bedcd7335422377fd2627f95f81d7e3e28dd61e5
|
||||
size 18173
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:543f80c91d04f44c4c62a06c9e44b334826d5bd92f2b17a4eeeb7e15dfbed0c1
|
||||
size 29510
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8735caab11d89a689591c1d1ad5c1483d0c1fc01ce7b5933c7546ce2c2641d4a
|
||||
size 17162
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc3fcb72de86766e3b97ffe0802551d7597b688f2a52ef73938d91c9cfdf0633
|
||||
size 141868
|
||||
oid sha256:1bf2830f59241a4f4fe7b6d5a53576d5b5738f036044dc925794c4a035d1284b
|
||||
size 143545
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02f12178991ed984da4b2d0a9883250750b6e37e7c9ce8494ca12bb987f29ad4
|
||||
size 65455
|
||||
oid sha256:fb6a0347fa3b5639fa30cf4e353c85b5cab45bb07375bf622d46c896a6ff1c36
|
||||
size 65612
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4eb7b8997069e2a4b37cd27c33e2862cba1871e1b58e85feb0b1bc4da6a3fce8
|
||||
size 33631
|
||||
oid sha256:f3ad2e84a242788e35b221311348d3cf7053ae14501b668ba91015b4a51b81fd
|
||||
size 33797
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ff84f90db0deea1d8edc52a2067855cf277cc747f5c357dc0758ce72bbc6d0c8
|
||||
size 28014
|
||||
oid sha256:daeec92b6da82df24261e400005662a2cf367328837256873615bf5357d36ddc
|
||||
size 28212
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e8cd2089f07a93e3dc62e41d80a3253d800b4463947276ccd461428280aff6b8
|
||||
size 84644
|
||||
oid sha256:46ea20dea8e41276db4062ee86348833192398a826836a2bfbb7c065e8f91a46
|
||||
size 84802
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71f336f93dd3fe736187a38aba6c1f14420bb0da9d574603f6df2042bce8f8d1
|
||||
size 83116
|
||||
oid sha256:8bae76f03ed015e34032a531b21d238f0553a09335bc7ea545c9690631965754
|
||||
size 83259
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7840847edf48b171393418d6fc26d846d772fa8be919f55dfee086d85cab814
|
||||
size 51404
|
||||
oid sha256:90ff370cbe3d2e7ea25471dc29e2593100dcfbef4f25dec51f03003871e55e4e
|
||||
size 51563
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02f12178991ed984da4b2d0a9883250750b6e37e7c9ce8494ca12bb987f29ad4
|
||||
size 65455
|
||||
oid sha256:fb6a0347fa3b5639fa30cf4e353c85b5cab45bb07375bf622d46c896a6ff1c36
|
||||
size 65612
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02f12178991ed984da4b2d0a9883250750b6e37e7c9ce8494ca12bb987f29ad4
|
||||
size 65455
|
||||
oid sha256:fb6a0347fa3b5639fa30cf4e353c85b5cab45bb07375bf622d46c896a6ff1c36
|
||||
size 65612
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:93b7d7b0d7df69921015ece40ae3ab859c81cd3e78828143f660a481a72b73e2
|
||||
size 62305
|
||||
oid sha256:d5674b09940d6dfd1361a37289bcdeeb2a1ac7f04d9cff287552540a9ddae95c
|
||||
size 62470
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02f12178991ed984da4b2d0a9883250750b6e37e7c9ce8494ca12bb987f29ad4
|
||||
size 65455
|
||||
oid sha256:fb6a0347fa3b5639fa30cf4e353c85b5cab45bb07375bf622d46c896a6ff1c36
|
||||
size 65612
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6bb7a5a23947cd3a311c8d9526508c121beb4e0ee37f337bf8361845be10d172
|
||||
size 54415
|
||||
oid sha256:429218b356c23d6cb934ebbcb144e838e75332b6f67f61e96c3a88893d33d5c1
|
||||
size 54556
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:11e9f28e6131eae66e268b72daab6d404d1f1f8b0eba78eb11d4376284eb11e3
|
||||
size 54220
|
||||
oid sha256:94aa6527f1fbebb1a3c5ca4a7f09ddbd7b9d8d6f7c0ee1dcbb34a83180391f6b
|
||||
size 54352
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:50e8fedd31aaa5dbdb63e088709a5e237f8c5400ca7209390d837a07d9b86973
|
||||
size 52404
|
||||
oid sha256:80c54edb1449c682fd21efb6593d22e0a5b936a7ba4ff879cf38c19deaf9acc4
|
||||
size 52538
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9fb19cc1e169e81f301a12ff3e30b8118682248ec3c13ea29b54f87398a54c3e
|
||||
size 82977
|
||||
oid sha256:b434402aac34cb7888714911af5f2fb710d41e932fad31660b2dac5bbda6a748
|
||||
size 83136
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62f84bd6941a00ee6b006318b8ac9e659694c2749a3bfa1d8ca72ca2ce413c90
|
||||
size 62152
|
||||
oid sha256:38f16bdafa24411b2b4e33980871972073500c2e86bb74e7277d8b9d8155413a
|
||||
size 62300
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab4a977d119ae80ddb92198b9c26b4b42d842eba0fd0543896f55914018d34f2
|
||||
size 30548
|
||||
oid sha256:f411dee9755509ebf93d9452bcbd3979555775245798d12f819b515701d09556
|
||||
size 30696
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:66824bcff1cf8fdd0fd88c3e0272bd11eaf6fdd16d24120c9346c99378226eee
|
||||
size 24633
|
||||
oid sha256:fbc5042340e85c931531525fe05ef820e6d92a10f53d2c94b6878afb1ab776e0
|
||||
size 24796
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7d1745b2665a9329808f69e2750b50d59eac2d92216a25bae03a52907b6c7681
|
||||
size 80341
|
||||
oid sha256:fe2614c4255ff8ea6a4e25219f396b3a69da0283373cd8c06b8e3f9396e1c1b8
|
||||
size 80491
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9017e66bb482de6a517065efe2e9d66ce4eb561fb848ca29d3245a2abc02d4c0
|
||||
size 79209
|
||||
oid sha256:7c74fa9af01f3d7e3762416d6c40b3abff7159b70a8328db42be68c622cb93ce
|
||||
size 79359
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6fd8d9d809b65df1690bf132351eec86736224d50fdd1bc9f9d2e731a4669ad6
|
||||
size 47687
|
||||
oid sha256:af4734d49824617816471fd49a64b49c568b77780b1c84fc0ab9c6248ae57c92
|
||||
size 47843
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62f84bd6941a00ee6b006318b8ac9e659694c2749a3bfa1d8ca72ca2ce413c90
|
||||
size 62152
|
||||
oid sha256:38f16bdafa24411b2b4e33980871972073500c2e86bb74e7277d8b9d8155413a
|
||||
size 62300
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62f84bd6941a00ee6b006318b8ac9e659694c2749a3bfa1d8ca72ca2ce413c90
|
||||
size 62152
|
||||
oid sha256:38f16bdafa24411b2b4e33980871972073500c2e86bb74e7277d8b9d8155413a
|
||||
size 62300
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a8db3006e7ea4826022d47a4ae37dab2a7ddd064d4cc2c48b44deca0f158541c
|
||||
size 59301
|
||||
oid sha256:850ea9b0435ce5f46f6fb4c717a8746e864ec1a1a94a7be327b33b8b3156cecc
|
||||
size 59458
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62f84bd6941a00ee6b006318b8ac9e659694c2749a3bfa1d8ca72ca2ce413c90
|
||||
size 62152
|
||||
oid sha256:38f16bdafa24411b2b4e33980871972073500c2e86bb74e7277d8b9d8155413a
|
||||
size 62300
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:957b8712456f13d83857616e2ffab05c5d60f50e6b39037bb47af0d272c53b02
|
||||
size 51863
|
||||
oid sha256:307f3be8a656c4f66f30fc6aa7ae9b97871cb62084b52e66f4cc81186b521838
|
||||
size 52001
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:beac305c3cc0661633873276a99798b1fa693342bd4ab9424bc44759a1f745ec
|
||||
size 51695
|
||||
oid sha256:bf8dca9a57cc3b867a010568612c8f9f25983e27543e4980bd31183e6b6c0539
|
||||
size 51839
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a83be8296a2abd4a2bd28f08c8687cac1dddaaedce4ccf784538892da7b069c
|
||||
size 49831
|
||||
oid sha256:11ce6dfc9128bf150eedd9113272ff4a9b2eb9afd3262cab4b7d4c9ec0c8279b
|
||||
size 49980
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c7790afd67222ce61ea0b72f1af3c1877b7e88580a644524c33f18d6fe76137
|
||||
size 79096
|
||||
oid sha256:84f78878963f2ead0ca82985bb5686f56cdf33f5ee45470b066d9e3f4c13db03
|
||||
size 79239
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue