mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:06:00 +00:00
Room List Filters implementation (#2423)
This commit is contained in:
@@ -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]))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user