Room List Filters implementation (#2423)

This commit is contained in:
Mauro
2024-02-08 16:50:44 +01:00
committed by GitHub
parent e1d1e99ad4
commit 224e4e4881
18 changed files with 292 additions and 128 deletions
@@ -45,7 +45,7 @@ class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearch
.map(\.bindings.searchQuery)
.removeDuplicates()
.sink { [weak self] searchQuery in
self?.roomSummaryProvider.setFilter(.normalizedMatchRoomName(searchQuery))
self?.roomSummaryProvider.setFilter(.include(.init(query: searchQuery)))
}
.store(in: &cancellables)
@@ -60,7 +60,7 @@ class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearch
switch viewAction {
case .dismiss:
actionsSubject.send(.dismiss)
roomSummaryProvider.setFilter(.all) // This is a shared provider
roomSummaryProvider.setFilter(.include(.all)) // This is a shared provider
case .select(let roomID):
actionsSubject.send(.select(roomID: roomID))
case .reachedTop:
@@ -212,88 +212,3 @@ extension HomeScreenRoom {
avatarURL: details.avatarURL)
}
}
enum RoomListFilter: Int, CaseIterable, Identifiable {
var id: Int {
rawValue
}
case people
case rooms
case unreads
case favourites
case lowPriority
var localizedName: String {
switch self {
case .people:
return L10n.screenRoomlistFilterPeople
case .rooms:
return L10n.screenRoomlistFilterRooms
case .unreads:
return L10n.screenRoomlistFilterUnreads
case .favourites:
return L10n.screenRoomlistFilterFavourites
case .lowPriority:
return L10n.screenRoomlistFilterLowPriority
}
}
var complementaryFilter: RoomListFilter? {
switch self {
case .people:
return .rooms
case .rooms:
return .people
case .unreads:
return nil
case .favourites:
return .lowPriority
case .lowPriority:
return .favourites
}
}
}
final class RoomListFiltersState: ObservableObject {
@Published private var enabledFilters: Set<RoomListFilter>
init(enabledFilters: Set<RoomListFilter> = []) {
self.enabledFilters = enabledFilters
}
var sortedEnabledFilters: [RoomListFilter] {
enabledFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var sortedAvailableFilters: [RoomListFilter] {
var availableFilters = Set(RoomListFilter.allCases)
for filter in enabledFilters {
availableFilters.remove(filter)
if let complementaryFilter = filter.complementaryFilter {
availableFilters.remove(complementaryFilter)
}
}
return availableFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var isFiltering: Bool {
!enabledFilters.isEmpty
}
func set(_ filter: RoomListFilter, isEnabled: Bool) {
if isEnabled {
enabledFilters.insert(filter)
} else {
enabledFilters.remove(filter)
}
}
func clearFilters() {
enabledFilters.removeAll()
}
func isEnabled(_ filter: RoomListFilter) -> Bool {
enabledFilters.contains(filter)
}
}
@@ -83,7 +83,17 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.store(in: &cancellables)
appSettings.$roomListFiltersEnabled
.weakAssign(to: \.state.shouldShowFilters, on: self)
.sink { [weak self] value in
guard let self else {
return
}
if !value {
state.shouldShowFilters = false
state.filtersState.clearFilters()
} else {
state.shouldShowFilters = true
}
}
.store(in: &cancellables)
appSettings.$markAsUnreadEnabled
@@ -96,8 +106,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
let isSearchFieldFocused = context.$viewState.map(\.bindings.isSearchFieldFocused)
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
let enabledFilters = context.viewState.filtersState.$activeFilters
isSearchFieldFocused
.combineLatest(searchQuery)
.combineLatest(searchQuery, enabledFilters)
.removeDuplicates { $0 == $1 }
.map { _ in () }
.sink { [weak self] in
@@ -196,12 +207,13 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private func updateFilter() {
if state.shouldHideRoomList {
roomSummaryProvider?.setFilter(.none)
roomSummaryProvider?.setFilter(.excludeAll)
} else {
if state.bindings.isSearchFieldFocused {
roomSummaryProvider?.setFilter(.normalizedMatchRoomName(state.bindings.searchQuery))
roomSummaryProvider?.setFilter(.include(.init(query: state.bindings.searchQuery,
filters: state.filtersState.activeFilters)))
} else {
roomSummaryProvider?.setFilter(.all)
roomSummaryProvider?.setFilter(.include(.init(filters: state.filtersState.activeFilters)))
}
}
}
@@ -0,0 +1,119 @@
//
// Copyright 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.
//
import Combine
import Foundation
import MatrixRustSDK
enum RoomListFilter: Int, CaseIterable, Identifiable {
var id: Int {
rawValue
}
case people
case rooms
case unreads
case favourites
var localizedName: String {
switch self {
case .people:
return L10n.screenRoomlistFilterPeople
case .rooms:
return L10n.screenRoomlistFilterRooms
case .unreads:
return L10n.screenRoomlistFilterUnreads
case .favourites:
return L10n.screenRoomlistFilterFavourites
}
}
var incompatibleFilter: RoomListFilter? {
switch self {
case .people:
return .rooms
case .rooms:
return .people
case .unreads:
return nil
case .favourites:
// When we will have Low Priority we may need to return it here
return nil
}
}
var rustFilter: RoomListEntriesDynamicFilterKind? {
switch self {
case .people:
return .category(expect: .people)
case .rooms:
return .category(expect: .group)
case .unreads:
return .unread
case .favourites:
// Not implemented yet
return nil
}
}
}
final class RoomListFiltersState: ObservableObject {
@Published private(set) var activeFilters: Set<RoomListFilter>
init(activeFilters: Set<RoomListFilter> = []) {
self.activeFilters = activeFilters
}
var sortedActiveFilters: [RoomListFilter] {
activeFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var availableFilters: [RoomListFilter] {
var availableFilters = Set(RoomListFilter.allCases)
for filter in activeFilters {
availableFilters.remove(filter)
if let complementaryFilter = filter.incompatibleFilter {
availableFilters.remove(complementaryFilter)
}
}
return availableFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var isFiltering: Bool {
!activeFilters.isEmpty
}
func activateFilter(_ filter: RoomListFilter) {
if let incompatibleFilter = filter.incompatibleFilter,
activeFilters.contains(incompatibleFilter) {
fatalError("[RoomListFiltersState] adding mutually exclusive filters is not allowed")
}
activeFilters.insert(filter)
}
func deactivateFilter(_ filter: RoomListFilter) {
activeFilters.remove(filter)
}
func clearFilters() {
activeFilters.removeAll()
}
func isFilterActive(_ filter: RoomListFilter) -> Bool {
activeFilters.contains(filter)
}
}
@@ -22,9 +22,9 @@ struct RoomListFilterView: View {
var body: some View {
let binding = Binding<Bool>(get: {
state.isEnabled(filter)
state.isFilterActive(filter)
}, set: { isEnabled, _ in
state.set(filter, isEnabled: isEnabled)
isEnabled ? state.activateFilter(filter) : state.deactivateFilter(filter)
})
Toggle(isOn: binding) {
Text(filter.localizedName)
@@ -36,7 +36,7 @@ struct RoomListFilterView: View {
struct RoomListFilterView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomListFilterView(filter: .people, state: .init())
RoomListFilterView(filter: .people, state: .init(enabledFilters: [.people]))
RoomListFilterView(filter: .people, state: .init(activeFilters: [.people]))
}
}
@@ -31,10 +31,10 @@ struct RoomListFiltersView: View {
.hidden()
.frame(width: 0)
}
ForEach(state.sortedEnabledFilters) { filter in
ForEach(state.sortedActiveFilters) { filter in
RoomListFilterView(filter: filter, state: state)
}
ForEach(state.sortedAvailableFilters) { filter in
ForEach(state.availableFilters) { filter in
RoomListFilterView(filter: filter, state: state)
}
}
@@ -62,6 +62,6 @@ struct RoomListFiltersView: View {
struct RoomListFiltersView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomListFiltersView(state: .init())
RoomListFiltersView(state: .init(enabledFilters: [.rooms, .favourites]))
RoomListFiltersView(state: .init(activeFilters: [.rooms, .favourites]))
}
}
@@ -49,7 +49,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
.removeDuplicates()
.sink { [weak self] searchQuery in
guard let self else { return }
self.roomSummaryProvider?.setFilter(.normalizedMatchRoomName(searchQuery))
self.roomSummaryProvider?.setFilter(.include(.init(query: searchQuery)))
}
.store(in: &cancellables)
@@ -60,7 +60,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
switch viewAction {
case .cancel:
actionsSubject.send(.dismiss)
roomSummaryProvider?.setFilter(.all)
roomSummaryProvider?.setFilter(.include(.all))
case .send:
guard let roomID = state.selectedRoomID else {
fatalError()
@@ -49,14 +49,13 @@ class RoomProxy: RoomProxyProtocol {
var ownUserID: String {
room.ownUserId()
}
init?(roomListItem: RoomListItemProtocol,
room: RoomProtocol,
backgroundTaskService: BackgroundTaskServiceProtocol) async {
self.roomListItem = roomListItem
self.room = room
self.backgroundTaskService = backgroundTaskService
do {
timeline = try await TimelineProxy(timeline: room.timeline(), backgroundTaskService: backgroundTaskService)
} catch {
@@ -130,7 +129,7 @@ class RoomProxy: RoomProxyProtocol {
var canonicalAlias: String? {
room.canonicalAlias()
}
var avatarURL: URL? {
roomListItem.avatarUrl().flatMap(URL.init(string:))
}
@@ -25,6 +25,7 @@ enum MockRoomSummaryProviderState {
class MockRoomSummaryProvider: RoomSummaryProviderProtocol {
private let initialRooms: [RoomSummary]
private(set) var currentFilter: RoomSummaryProviderFilter?
private let roomListSubject: CurrentValueSubject<[RoomSummary], Never>
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
@@ -60,17 +61,16 @@ class MockRoomSummaryProvider: RoomSummaryProviderProtocol {
func updateVisibleRange(_ range: Range<Int>) { }
func setFilter(_ filter: RoomSummaryProviderFilter) {
currentFilter = filter
switch filter {
case .all:
roomListSubject.send(initialRooms)
case .none:
roomListSubject.send([])
case .normalizedMatchRoomName(let filter):
if filter.isEmpty {
roomListSubject.send(initialRooms)
case let .include(predicate):
if let query = predicate.query, !query.isEmpty {
roomListSubject.send(initialRooms.filter { $0.name?.localizedCaseInsensitiveContains(query) ?? false })
} else {
roomListSubject.send(initialRooms.filter { $0.name?.localizedCaseInsensitiveContains(filter) ?? false })
roomListSubject.send(initialRooms)
}
case .excludeAll:
roomListSubject.send([])
}
}
}
@@ -102,7 +102,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
})
// Forces the listener above to be called with the current state
setFilter(.all)
setFilter(.include(.all))
listUpdatesTaskHandle = listUpdatesSubscriptionResult?.entriesStream
@@ -151,12 +151,16 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
func setFilter(_ filter: RoomSummaryProviderFilter) {
switch filter {
case .none:
case .excludeAll:
_ = listUpdatesSubscriptionResult?.controller.setFilter(kind: .none)
case .all:
_ = listUpdatesSubscriptionResult?.controller.setFilter(kind: .allNonLeft)
case .normalizedMatchRoomName(let query):
_ = listUpdatesSubscriptionResult?.controller.setFilter(kind: .normalizedMatchRoomName(pattern: query.lowercased()))
case let .include(predicate):
var filters = predicate.filters.compactMap(\.rustFilter)
if let query = predicate.query {
filters.append(.normalizedMatchRoomName(pattern: query.lowercased()))
}
// We never want to show left rooms.
filters.append(.nonLeft)
_ = listUpdatesSubscriptionResult?.controller.setFilter(kind: .all(filters: filters))
}
}
@@ -87,10 +87,28 @@ enum RoomSummary: CustomStringConvertible, Equatable {
}
}
enum RoomSummaryProviderFilter {
case none
case all
case normalizedMatchRoomName(String)
enum RoomSummaryProviderFilter: Equatable {
struct Predicate: Equatable {
let query: String?
let filters: Set<RoomListFilter>
static var all: Predicate {
Predicate()
}
/// - Parameters:
/// - query: If provided the filter will do a normalized search, default is nil
/// - filters: Additional filters that can be provided for further filtering the room list, default is empty which means no additional filtering is done
init(query: String? = nil, filters: Set<RoomListFilter> = []) {
self.query = query
self.filters = filters
}
}
/// Filters out everything
case excludeAll
/// Includes only the items that satisfy the predicate logic
case include(Predicate)
}
protocol RoomSummaryProviderProtocol {
@@ -92,8 +92,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError> {
guard let eventID = itemID.eventID
else { return .success(()) }
guard let eventID = itemID.eventID else {
return .failure(.generic)
}
switch await roomProxy.timeline.sendReadReceipt(for: eventID,
type: appSettings.sendReadReceiptsEnabled ? .read : .readPrivate) {