Merge pull request #2405 from element-hq/feature/fga/room_list_refact_search
RoomList : rework how search is done to prepare for later filtering
This commit is contained in:
@@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface RoomListEvents {
|
||||
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||
data object DismissRecoveryKeyPrompt : RoomListEvents
|
||||
|
||||
@@ -37,6 +37,8 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -76,6 +78,7 @@ class RoomListPresenter @Inject constructor(
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val indicatorService: IndicatorService,
|
||||
private val searchPresenter: Presenter<RoomListSearchState>,
|
||||
private val migrationScreenPresenter: MigrationScreenPresenter,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
) : Presenter<RoomListState> {
|
||||
@@ -89,9 +92,8 @@ class RoomListPresenter @Inject constructor(
|
||||
val roomList by produceState(initialValue = AsyncData.Loading()) {
|
||||
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
||||
}
|
||||
val filteredRoomList by roomListDataSource.filteredRooms.collectAsState()
|
||||
val filter by roomListDataSource.filter.collectAsState()
|
||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||
val searchState = searchPresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
roomListDataSource.launchIn(this)
|
||||
@@ -122,21 +124,14 @@ class RoomListPresenter @Inject constructor(
|
||||
// Avatar indicator
|
||||
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
||||
|
||||
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||
|
||||
fun handleEvents(event: RoomListEvents) {
|
||||
when (event) {
|
||||
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
|
||||
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
||||
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
|
||||
RoomListEvents.ToggleSearchResults -> {
|
||||
if (displaySearchResults) {
|
||||
roomListDataSource.updateFilter("")
|
||||
}
|
||||
displaySearchResults = !displaySearchResults
|
||||
}
|
||||
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
is RoomListEvents.ShowContextMenu -> {
|
||||
coroutineScope.showContextMenu(event, contextMenu)
|
||||
}
|
||||
@@ -175,16 +170,14 @@ class RoomListPresenter @Inject constructor(
|
||||
matrixUser = matrixUser.value,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
roomList = roomList,
|
||||
filter = filter,
|
||||
filteredRoomList = filteredRoomList,
|
||||
displayVerificationPrompt = displayVerificationPrompt,
|
||||
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
|
||||
snackbarMessage = snackbarMessage,
|
||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||
invitesState = inviteStateDataSource.inviteState(),
|
||||
displaySearchResults = displaySearchResults,
|
||||
contextMenu = contextMenu.value,
|
||||
leaveRoomState = leaveRoomState,
|
||||
searchState = searchState,
|
||||
displayMigrationStatus = isMigrating,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -30,16 +31,14 @@ data class RoomListState(
|
||||
val matrixUser: MatrixUser?,
|
||||
val showAvatarIndicator: Boolean,
|
||||
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
|
||||
val filter: String?,
|
||||
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
|
||||
val displayVerificationPrompt: Boolean,
|
||||
val displayRecoveryKeyPrompt: Boolean,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val invitesState: InvitesState,
|
||||
val displaySearchResults: Boolean,
|
||||
val contextMenu: ContextMenu,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val searchState: RoomListSearchState,
|
||||
val displayMigrationStatus: Boolean,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
@@ -41,14 +42,13 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState().copy(hasNetworkConnection = false),
|
||||
aRoomListState().copy(invitesState = InvitesState.SeenInvites),
|
||||
aRoomListState().copy(invitesState = InvitesState.NewInvites),
|
||||
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
||||
aRoomListState().copy(displaySearchResults = true),
|
||||
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")),
|
||||
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)),
|
||||
aRoomListState().copy(displayRecoveryKeyPrompt = true),
|
||||
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
|
||||
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
|
||||
aRoomListState().copy(matrixUser = null, displayMigrationStatus = true),
|
||||
aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,16 +56,14 @@ internal fun aRoomListState() = RoomListState(
|
||||
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
|
||||
showAvatarIndicator = false,
|
||||
roomList = AsyncData.Success(aRoomListRoomSummaryList()),
|
||||
filter = "filter",
|
||||
filteredRoomList = aRoomListRoomSummaryList(),
|
||||
hasNetworkConnection = true,
|
||||
snackbarMessage = null,
|
||||
displayVerificationPrompt = false,
|
||||
displayRecoveryKeyPrompt = false,
|
||||
invitesState = InvitesState.NoInvites,
|
||||
displaySearchResults = false,
|
||||
contextMenu = RoomListState.ContextMenu.Hidden,
|
||||
leaveRoomState = aLeaveRoomState(),
|
||||
searchState = aRoomListSearchState(),
|
||||
displayMigrationStatus = false,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -118,8 +118,8 @@ fun RoomListView(
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchResultView(
|
||||
state = state,
|
||||
RoomListSearchView(
|
||||
state = state.searchState,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||
modifier = Modifier
|
||||
@@ -207,8 +207,7 @@ private fun RoomListContent(
|
||||
RoomListTopBar(
|
||||
matrixUser = state.matrixUser,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = state.displaySearchResults,
|
||||
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||
areSearchResultsDisplayed = state.searchState.isSearchActive,
|
||||
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onOpenSettings = onOpenSettings,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -87,7 +86,6 @@ fun RoomListTopBar(
|
||||
matrixUser: MatrixUser?,
|
||||
showAvatarIndicator: Boolean,
|
||||
areSearchResultsDisplayed: Boolean,
|
||||
onFilterChanged: (String) -> Unit,
|
||||
onToggleSearch: () -> Unit,
|
||||
onMenuActionClicked: (RoomListMenuAction) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
@@ -95,15 +93,6 @@ fun RoomListTopBar(
|
||||
displayMenuItems: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun closeFilter() {
|
||||
onFilterChanged("")
|
||||
}
|
||||
|
||||
BackHandler(enabled = areSearchResultsDisplayed) {
|
||||
closeFilter()
|
||||
onToggleSearch()
|
||||
}
|
||||
|
||||
DefaultRoomListTopBar(
|
||||
matrixUser = matrixUser,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
|
||||
@@ -25,15 +25,11 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -54,9 +50,7 @@ class RoomListDataSource @Inject constructor(
|
||||
observeNotificationSettings()
|
||||
}
|
||||
|
||||
private val _filter = MutableStateFlow("")
|
||||
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
|
||||
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
||||
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
|
||||
@@ -72,29 +66,9 @@ class RoomListDataSource @Inject constructor(
|
||||
replaceWith(roomSummaries)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
combine(
|
||||
_filter,
|
||||
_allRooms
|
||||
) { filterValue, allRoomsValue ->
|
||||
when {
|
||||
filterValue.isEmpty() -> emptyList()
|
||||
else -> allRoomsValue.filter { it.name.contains(filterValue, ignoreCase = true) }
|
||||
}.toImmutableList()
|
||||
}
|
||||
.onEach {
|
||||
_filteredRooms.value = it
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun updateFilter(filterValue: String) {
|
||||
_filter.value = filterValue
|
||||
}
|
||||
|
||||
val filter: StateFlow<String> = _filter
|
||||
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
|
||||
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeNotificationSettings() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesTo(SessionScope::class)
|
||||
@Module
|
||||
interface RoomListModule {
|
||||
@Binds
|
||||
fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter<RoomListSearchState>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
sealed interface RoomListSearchEvents {
|
||||
data object ToggleSearchVisibility : RoomListSearchEvents
|
||||
data class QueryChanged(val query: String) : RoomListSearchEvents
|
||||
data object ClearQuery : RoomListSearchEvents
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
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.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 50
|
||||
|
||||
class RoomListSearchPresenter @Inject constructor(
|
||||
private val roomListService: RoomListService,
|
||||
private val roomSummaryFactory: RoomListRoomSummaryFactory,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<RoomListSearchState> {
|
||||
@Composable
|
||||
override fun present(): RoomListSearchState {
|
||||
var isSearchActive by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var searchQuery by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val roomList = remember {
|
||||
roomListService.createRoomList(
|
||||
coroutineScope = coroutineScope,
|
||||
pageSize = PAGE_SIZE,
|
||||
initialFilter = RoomListFilter.all(RoomListFilter.None),
|
||||
source = RoomList.Source.All,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
roomList.loadAllIncrementally(this)
|
||||
}
|
||||
LaunchedEffect(key1 = searchQuery) {
|
||||
val filter = if (searchQuery.isBlank()) {
|
||||
RoomListFilter.all(RoomListFilter.None)
|
||||
} else {
|
||||
RoomListFilter.all(RoomListFilter.NonLeft, RoomListFilter.NormalizedMatchRoomName(searchQuery))
|
||||
}
|
||||
roomList.updateFilter(filter)
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomListSearchEvents) {
|
||||
when (event) {
|
||||
RoomListSearchEvents.ClearQuery -> {
|
||||
searchQuery = ""
|
||||
}
|
||||
is RoomListSearchEvents.QueryChanged -> {
|
||||
searchQuery = event.query
|
||||
}
|
||||
RoomListSearchEvents.ToggleSearchVisibility -> {
|
||||
isSearchActive = !isSearchActive
|
||||
searchQuery = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val searchResults by roomList
|
||||
.rememberMappedSummaries()
|
||||
.collectAsState(initial = persistentListOf())
|
||||
|
||||
return RoomListSearchState(
|
||||
isSearchActive = isSearchActive,
|
||||
query = searchQuery,
|
||||
results = searchResults,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomList.rememberMappedSummaries() = remember {
|
||||
summaries
|
||||
.map { roomSummaries ->
|
||||
roomSummaries
|
||||
.filterIsInstance<RoomSummary.Filled>()
|
||||
.map(roomSummaryFactory::create)
|
||||
.toPersistentList()
|
||||
}
|
||||
.flowOn(coroutineDispatchers.computation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class RoomListSearchState(
|
||||
val isSearchActive: Boolean,
|
||||
val query: String,
|
||||
val results: ImmutableList<RoomListRoomSummary>,
|
||||
val eventSink: (RoomListSearchEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.roomlist.impl.aRoomListRoomSummaryList
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState> {
|
||||
override val values: Sequence<RoomListSearchState>
|
||||
get() = sequenceOf(
|
||||
aRoomListSearchState(),
|
||||
aRoomListSearchState(
|
||||
isSearchActive = true,
|
||||
query = "Test",
|
||||
results = aRoomListRoomSummaryList()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomListSearchState(
|
||||
isSearchActive: Boolean = false,
|
||||
query: String = "",
|
||||
results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
|
||||
) = RoomListSearchState(
|
||||
isSearchActive = isSearchActive,
|
||||
query = query,
|
||||
results = results,
|
||||
eventSink = { },
|
||||
)
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -25,32 +26,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomlist.impl.RoomListEvents
|
||||
import io.element.android.features.roomlist.impl.RoomListState
|
||||
import io.element.android.features.roomlist.impl.aRoomListState
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
import io.element.android.features.roomlist.impl.contentType
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
@@ -68,26 +60,30 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun RoomListSearchResultView(
|
||||
state: RoomListState,
|
||||
internal fun RoomListSearchView(
|
||||
state: RoomListSearchState,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(enabled = state.isSearchActive) {
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.displaySearchResults,
|
||||
visible = state.isSearchActive,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.applyIf(state.displaySearchResults, ifTrue = {
|
||||
.applyIf(state.isSearchActive, ifTrue = {
|
||||
// Disable input interaction to underlying views
|
||||
pointerInput(Unit) {}
|
||||
})
|
||||
) {
|
||||
if (state.displaySearchResults) {
|
||||
RoomListSearchResultContent(
|
||||
if (state.isSearchActive) {
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
@@ -99,15 +95,15 @@ internal fun RoomListSearchResultView(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomListSearchResultContent(
|
||||
state: RoomListState,
|
||||
private fun RoomListSearchContent(
|
||||
state: RoomListSearchState,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
val strokeWidth = 1.dp
|
||||
fun onBackButtonPressed() {
|
||||
state.eventSink(RoomListEvents.ToggleSearchResults)
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||
@@ -126,7 +122,7 @@ private fun RoomListSearchResultContent(
|
||||
},
|
||||
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
|
||||
title = {
|
||||
val filter = state.filter.orEmpty()
|
||||
val filter = state.query
|
||||
val focusRequester = FocusRequester()
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
@@ -134,7 +130,7 @@ private fun RoomListSearchResultContent(
|
||||
.focusRequester(focusRequester),
|
||||
value = filter,
|
||||
singleLine = true,
|
||||
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||
onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
@@ -147,7 +143,7 @@ private fun RoomListSearchResultContent(
|
||||
trailingIcon = {
|
||||
if (filter.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
state.eventSink(RoomListEvents.UpdateFilter(""))
|
||||
state.eventSink(RoomListSearchEvents.ClearQuery)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
@@ -158,8 +154,8 @@ private fun RoomListSearchResultContent(
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(state.displaySearchResults) {
|
||||
if (state.displaySearchResults) {
|
||||
LaunchedEffect(state.isSearchActive) {
|
||||
if (state.isSearchActive) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
@@ -168,39 +164,16 @@ private fun RoomListSearchResultContent(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val lazyListState = rememberLazyListState()
|
||||
val visibleRange by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
|
||||
val size = layoutInfo.visibleItemsInfo.size
|
||||
firstItemIndex until firstItemIndex + size
|
||||
}
|
||||
}
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(
|
||||
consumed: Velocity,
|
||||
available: Velocity
|
||||
): Velocity {
|
||||
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
|
||||
return super.onPostFling(consumed, available)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = lazyListState,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(
|
||||
items = state.filteredRoomList,
|
||||
items = state.results,
|
||||
contentType = { room -> room.contentType() },
|
||||
) { room ->
|
||||
RoomSummaryRow(
|
||||
@@ -216,9 +189,9 @@ private fun RoomListSearchResultContent(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomListSearchResultContentPreview() = ElementPreview {
|
||||
RoomListSearchResultContent(
|
||||
state = aRoomListState(),
|
||||
internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = {},
|
||||
onRoomLongClicked = {}
|
||||
)
|
||||
@@ -33,6 +33,9 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF
|
||||
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
@@ -54,7 +57,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
@@ -77,6 +79,7 @@ import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RoomListPresenterTests {
|
||||
@get:Rule
|
||||
@@ -145,24 +148,6 @@ class RoomListPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - should filter room with success`() = runTest {
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val presenter = createRoomListPresenter(coroutineScope = scope)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val withUserState = awaitItem()
|
||||
assertThat(withUserState.filter).isEqualTo("")
|
||||
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
|
||||
val withFilterState = awaitItem()
|
||||
assertThat(withFilterState.filter).isEqualTo("t")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - load 1 room with success`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
@@ -174,7 +159,7 @@ class RoomListPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 16 }.last()
|
||||
val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last()
|
||||
// Room list is loaded with 16 placeholders
|
||||
val initialItems = initialState.roomList.dataOrNull().orEmpty()
|
||||
assertThat(initialItems.size).isEqualTo(16)
|
||||
@@ -196,51 +181,7 @@ class RoomListPresenterTests {
|
||||
numberOfUnreadMessages = 2,
|
||||
)
|
||||
)
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - load 1 room with success and filter rooms`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService
|
||||
)
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
roomListService.postAllRooms(
|
||||
listOf(
|
||||
aRoomSummaryFilled(
|
||||
numUnreadMentions = 1,
|
||||
numUnreadMessages = 2,
|
||||
)
|
||||
)
|
||||
)
|
||||
skipItems(3)
|
||||
val loadedState = awaitItem()
|
||||
// Test filtering with result
|
||||
assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1)
|
||||
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
|
||||
skipItems(1)
|
||||
val withFilteredRoomState = awaitItem()
|
||||
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
|
||||
assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
|
||||
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
|
||||
assertThat(withFilteredRoomState.filteredRoomList.first()).isEqualTo(
|
||||
createRoomListRoomSummary(
|
||||
numberOfUnreadMentions = 1,
|
||||
numberOfUnreadMessages = 2,
|
||||
)
|
||||
)
|
||||
// Test filtering without result
|
||||
withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
|
||||
skipItems(1)
|
||||
val withNotFilteredRoomState = awaitItem()
|
||||
assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
|
||||
assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -515,7 +456,6 @@ class RoomListPresenterTests {
|
||||
|
||||
// The migration screen is not shown anymore
|
||||
assertThat(awaitItem().displayMigrationStatus).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -572,7 +512,8 @@ class RoomListPresenterTests {
|
||||
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
|
||||
matrixClient = client,
|
||||
migrationScreenStore = InMemoryMigrationScreenStore(),
|
||||
)
|
||||
),
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -598,6 +539,7 @@ class RoomListPresenterTests {
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
|
||||
),
|
||||
migrationScreenPresenter = migrationScreenPresenter,
|
||||
searchPresenter = searchPresenter,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class RoomListSearchPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createRoomListSearchPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
assertThat(state.query).isEmpty()
|
||||
assertThat(state.results).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggle search visibility`() = runTest {
|
||||
val presenter = createRoomListSearchPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isTrue()
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - query search changes`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListSearchPresenter(roomListService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(
|
||||
roomListService.allRooms.currentFilter.value
|
||||
).isEqualTo(
|
||||
RoomListFilter.all(
|
||||
RoomListFilter.None,
|
||||
)
|
||||
)
|
||||
state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.query).isEqualTo("Search")
|
||||
assertThat(
|
||||
roomListService.allRooms.currentFilter.value
|
||||
).isEqualTo(
|
||||
RoomListFilter.all(
|
||||
RoomListFilter.NonLeft,
|
||||
RoomListFilter.NormalizedMatchRoomName("Search")
|
||||
)
|
||||
)
|
||||
state.eventSink(RoomListSearchEvents.ClearQuery)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.query).isEmpty()
|
||||
assertThat(
|
||||
roomListService.allRooms.currentFilter.value
|
||||
).isEqualTo(
|
||||
RoomListFilter.all(
|
||||
RoomListFilter.None,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room list changes`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListSearchPresenter(roomListService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.results).isEmpty()
|
||||
}
|
||||
roomListService.postAllRooms(
|
||||
listOf(
|
||||
RoomSummary.Empty("1"),
|
||||
aRoomSummaryFilled()
|
||||
)
|
||||
)
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.results).hasSize(1)
|
||||
}
|
||||
roomListService.postAllRooms(emptyList())
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.results).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TestScope.createRoomListSearchPresenter(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
): RoomListSearchPresenter {
|
||||
return RoomListSearchPresenter(
|
||||
roomListService = roomListService,
|
||||
roomSummaryFactory = RoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
|
||||
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
}
|
||||
@@ -28,11 +28,26 @@ import kotlin.time.Duration
|
||||
* Can be retrieved from [RoomListService] methods.
|
||||
*/
|
||||
interface RoomList {
|
||||
/**
|
||||
* The loading state of the room list.
|
||||
*/
|
||||
sealed interface LoadingState {
|
||||
data object NotLoaded : LoadingState
|
||||
data class Loaded(val numberOfRooms: Int) : LoadingState
|
||||
}
|
||||
|
||||
/**
|
||||
* The source of the room list data.
|
||||
* All: all rooms except invites.
|
||||
* Invites: only invites.
|
||||
*
|
||||
* To apply some dynamic filtering on top of that, use [DynamicRoomList].
|
||||
*/
|
||||
enum class Source {
|
||||
All,
|
||||
Invites,
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of room summaries as a flow.
|
||||
*/
|
||||
|
||||
@@ -18,38 +18,68 @@ package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
sealed interface RoomListFilter {
|
||||
companion object {
|
||||
/**
|
||||
* Create a filter that matches all the given filters.
|
||||
*/
|
||||
fun all(vararg filters: RoomListFilter): RoomListFilter {
|
||||
return All(filters.toList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that matches any of the given filters.
|
||||
*/
|
||||
fun any(vararg filters: RoomListFilter): RoomListFilter {
|
||||
return Any(filters.toList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter that matches all the given filters.
|
||||
*/
|
||||
data class All(
|
||||
val filters: List<RoomListFilter>
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches any of the given filters.
|
||||
*/
|
||||
data class Any(
|
||||
val filters: List<RoomListFilter>
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are not left.
|
||||
*/
|
||||
data object NonLeft : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are unread.
|
||||
*/
|
||||
data object Unread : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches either Group or People rooms.
|
||||
*/
|
||||
sealed interface Category : RoomListFilter {
|
||||
data object Group : Category
|
||||
data object People : Category
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter that matches no room.
|
||||
*/
|
||||
data object None : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms with a name using a normalized match.
|
||||
*/
|
||||
data class NormalizedMatchRoomName(
|
||||
val pattern: String
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms with a name using a fuzzy match.
|
||||
*/
|
||||
data class FuzzyMatchRoomName(
|
||||
val pattern: String
|
||||
) : RoomListFilter
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,20 @@ interface RoomListService {
|
||||
data object Hide : SyncIndicator
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a room list that can be used to load more rooms and filter them dynamically.
|
||||
* @param coroutineScope the scope to use for the room list. When the scope will be closed, the room list will be closed too.
|
||||
* @param pageSize the number of rooms to load at once.
|
||||
* @param initialFilter the initial filter to apply to the rooms.
|
||||
* @param source the source of the rooms, either all rooms or invites.
|
||||
*/
|
||||
fun createRoomList(
|
||||
coroutineScope: CoroutineScope,
|
||||
pageSize: Int,
|
||||
initialFilter: RoomListFilter,
|
||||
source: RoomList.Source,
|
||||
): DynamicRoomList
|
||||
|
||||
/**
|
||||
* returns a [DynamicRoomList] object of all rooms we want to display.
|
||||
* This will exclude some rooms like the invites, or spaces.
|
||||
|
||||
@@ -197,10 +197,10 @@ class RustMatrixClient(
|
||||
RustRoomListService(
|
||||
innerRoomListService = innerRoomListService,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
sessionDispatcher = sessionDispatcher,
|
||||
roomListFactory = RoomListFactory(
|
||||
innerRoomListService = innerRoomListService,
|
||||
coroutineScope = sessionCoroutineScope,
|
||||
dispatcher = sessionDispatcher,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
|
||||
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.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -30,13 +29,14 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||
import org.matrix.rustcomponents.sdk.RoomListService
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
|
||||
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
|
||||
|
||||
internal class RoomListFactory(
|
||||
private val innerRoomListService: InnerRoomListService,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val innerRoomListService: RoomListService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
) {
|
||||
/**
|
||||
@@ -44,18 +44,21 @@ internal class RoomListFactory(
|
||||
*/
|
||||
fun createRoomList(
|
||||
pageSize: Int,
|
||||
coroutineScope: CoroutineScope = sessionCoroutineScope,
|
||||
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
initialFilter: RoomListFilter = RoomListFilter.all(),
|
||||
innerProvider: suspend () -> InnerRoomList
|
||||
): DynamicRoomList {
|
||||
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
|
||||
val summariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
|
||||
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, dispatcher, roomSummaryDetailsFactory)
|
||||
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory)
|
||||
// Makes sure we don't miss any events
|
||||
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
|
||||
val currentFilter = MutableStateFlow(initialFilter)
|
||||
val loadedPages = MutableStateFlow(1)
|
||||
var innerRoomList: InnerRoomList? = null
|
||||
coroutineScope.launch(dispatcher) {
|
||||
|
||||
coroutineScope.launch(coroutineContext) {
|
||||
innerRoomList = innerProvider()
|
||||
innerRoomList?.let { innerRoomList ->
|
||||
innerRoomList.entriesFlow(
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.libraries.matrix.impl.roomlist
|
||||
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -28,11 +27,12 @@ import org.matrix.rustcomponents.sdk.RoomListServiceInterface
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class RoomSummaryListProcessor(
|
||||
private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
|
||||
private val roomListService: RoomListServiceInterface,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val coroutineContext: CoroutineContext,
|
||||
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
) {
|
||||
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
|
||||
@@ -130,7 +130,7 @@ class RoomSummaryListProcessor(
|
||||
return builtRoomSummary
|
||||
}
|
||||
|
||||
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(dispatcher) {
|
||||
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {
|
||||
mutex.withLock {
|
||||
val mutableRoomSummaries = roomSummaries.value.toMutableList()
|
||||
block(mutableRoomSummaries)
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -42,10 +43,31 @@ private const val DEFAULT_PAGE_SIZE = 20
|
||||
internal class RustRoomListService(
|
||||
private val innerRoomListService: InnerRustRoomListService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
roomListFactory: RoomListFactory,
|
||||
private val sessionDispatcher: CoroutineDispatcher,
|
||||
private val roomListFactory: RoomListFactory,
|
||||
) : RoomListService {
|
||||
override fun createRoomList(
|
||||
coroutineScope: CoroutineScope,
|
||||
pageSize: Int,
|
||||
initialFilter: RoomListFilter,
|
||||
source: RoomList.Source
|
||||
): DynamicRoomList {
|
||||
return roomListFactory.createRoomList(
|
||||
pageSize = pageSize,
|
||||
initialFilter = initialFilter,
|
||||
coroutineScope = coroutineScope,
|
||||
coroutineContext = sessionDispatcher,
|
||||
) {
|
||||
when (source) {
|
||||
RoomList.Source.All -> innerRoomListService.allRooms()
|
||||
RoomList.Source.Invites -> innerRoomListService.invites()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
coroutineContext = sessionDispatcher,
|
||||
initialFilter = RoomListFilter.all(RoomListFilter.NonLeft),
|
||||
) {
|
||||
innerRoomListService.allRooms()
|
||||
@@ -53,6 +75,7 @@ internal class RustRoomListService(
|
||||
|
||||
override val invites: RoomList = roomListFactory.createRoomList(
|
||||
pageSize = Int.MAX_VALUE,
|
||||
coroutineContext = sessionDispatcher,
|
||||
) {
|
||||
innerRoomListService.invites()
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class RoomSummaryListProcessorTests {
|
||||
private fun TestScope.createProcessor() = RoomSummaryListProcessor(
|
||||
summaries,
|
||||
fakeRoomListService,
|
||||
dispatcher = StandardTestDispatcher(testScheduler),
|
||||
coroutineContext = StandardTestDispatcher(testScheduler),
|
||||
roomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ fun aRoomSummaryFilled(
|
||||
)
|
||||
)
|
||||
|
||||
fun aRoomSummaryFilled(
|
||||
details: RoomSummaryDetails = aRoomSummaryDetails(),
|
||||
) = RoomSummary.Filled(details)
|
||||
|
||||
fun aRoomSummaryDetails(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String = A_ROOM_NAME,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -59,13 +60,20 @@ class FakeRoomListService : RoomListService {
|
||||
var latestSlidingSyncRange: IntRange? = null
|
||||
private set
|
||||
|
||||
override val allRooms: DynamicRoomList = SimplePagedRoomList(
|
||||
override fun createRoomList(coroutineScope: CoroutineScope, pageSize: Int, initialFilter: RoomListFilter, source: RoomList.Source): DynamicRoomList {
|
||||
return when (source) {
|
||||
RoomList.Source.All -> allRooms
|
||||
RoomList.Source.Invites -> invites
|
||||
}
|
||||
}
|
||||
|
||||
override val allRooms = SimplePagedRoomList(
|
||||
allRoomSummariesFlow,
|
||||
allRoomsLoadingStateFlow,
|
||||
MutableStateFlow(RoomListFilter.all())
|
||||
)
|
||||
|
||||
override val invites: RoomList = SimplePagedRoomList(
|
||||
override val invites = SimplePagedRoomList(
|
||||
inviteRoomSummariesFlow,
|
||||
inviteRoomsLoadingStateFlow,
|
||||
MutableStateFlow(RoomListFilter.all())
|
||||
|
||||
@@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
|
||||
data class SimplePagedRoomList(
|
||||
override val summaries: StateFlow<List<RoomSummary>>,
|
||||
override val summaries: MutableStateFlow<List<RoomSummary>>,
|
||||
override val loadingState: StateFlow<RoomList.LoadingState>,
|
||||
override val currentFilter: MutableStateFlow<RoomListFilter>
|
||||
) : DynamicRoomList {
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.dateformatter.impl.DateFormatters
|
||||
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
|
||||
@@ -71,6 +72,21 @@ class RoomListScreen(
|
||||
private val featureFlagService = DefaultFeatureFlagService(
|
||||
providers = setOf(StaticFeatureFlagProvider())
|
||||
)
|
||||
private val roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
|
||||
localDateTimeProvider = dateTimeProvider,
|
||||
dateFormatters = dateFormatters
|
||||
),
|
||||
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
|
||||
sp = stringProvider,
|
||||
roomMembershipContentFormatter = RoomMembershipContentFormatter(
|
||||
matrixClient = matrixClient,
|
||||
sp = stringProvider
|
||||
),
|
||||
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
|
||||
stateContentFormatter = StateContentFormatter(stringProvider),
|
||||
),
|
||||
)
|
||||
private val presenter = RoomListPresenter(
|
||||
client = matrixClient,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -80,21 +96,7 @@ class RoomListScreen(
|
||||
leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers),
|
||||
roomListDataSource = RoomListDataSource(
|
||||
roomListService = matrixClient.roomListService,
|
||||
roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
|
||||
localDateTimeProvider = dateTimeProvider,
|
||||
dateFormatters = dateFormatters
|
||||
),
|
||||
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
|
||||
sp = stringProvider,
|
||||
roomMembershipContentFormatter = RoomMembershipContentFormatter(
|
||||
matrixClient = matrixClient,
|
||||
sp = stringProvider
|
||||
),
|
||||
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
|
||||
stateContentFormatter = StateContentFormatter(stringProvider),
|
||||
),
|
||||
),
|
||||
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
notificationSettingsService = matrixClient.notificationSettingsService(),
|
||||
appScope = Singleton.appScope
|
||||
@@ -110,6 +112,11 @@ class RoomListScreen(
|
||||
matrixClient = matrixClient,
|
||||
migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE))
|
||||
),
|
||||
searchPresenter = RoomListSearchPresenter(
|
||||
roomListService = matrixClient.roomListService,
|
||||
roomSummaryFactory = roomListRoomSummaryFactory,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
),
|
||||
sessionPreferencesStore = DefaultSessionPreferencesStore(
|
||||
context = context,
|
||||
sessionId = matrixClient.sessionId,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user