From 224e4e4881a3b671a71ae28655e90a88b6c89eac Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 8 Feb 2024 16:50:44 +0100 Subject: [PATCH] Room List Filters implementation (#2423) --- ElementX.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../GlobalSearchScreenViewModel.swift | 4 +- .../Screens/HomeScreen/HomeScreenModels.swift | 85 ------------- .../HomeScreen/HomeScreenViewModel.swift | 22 +++- .../View/Filters/RoomListFilterModels.swift | 119 ++++++++++++++++++ .../View/Filters/RoomListFilterView.swift | 6 +- .../View/Filters/RoomListFiltersView.swift | 6 +- .../MessageForwardingScreenViewModel.swift | 4 +- .../Sources/Services/Room/RoomProxy.swift | 5 +- .../RoomSummary/MockRoomSummaryProvider.swift | 16 +-- .../RoomSummary/RoomSummaryProvider.swift | 16 ++- .../RoomSummaryProviderProtocol.swift | 26 +++- .../RoomTimelineController.swift | 5 +- .../Sources/HomeScreenViewModelTests.swift | 19 ++- .../Sources/RoomListFiltersStateTests.swift | 70 +++++++++++ changelog.d/pr-2423.wip | 1 + project.yml | 2 +- 18 files changed, 292 insertions(+), 128 deletions(-) create mode 100644 ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift create mode 100644 UnitTests/Sources/RoomListFiltersStateTests.swift create mode 100644 changelog.d/pr-2423.wip diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 7519c306e3..c0b1a3947b 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -303,6 +303,7 @@ 4BB51476A29E7E27BC14EA22 /* UserDetailsEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */; }; 4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7884BD256C091EB511B2EDF /* AppLockSetupPINScreenViewModelProtocol.swift */; }; 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */; }; + 4C8C0C9FC10BA73AB7780534 /* RoomListFiltersStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */; }; 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */; }; 4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */; }; 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */; }; @@ -968,6 +969,7 @@ F421FD5979EF53C8204BDC77 /* SecureBackupLogoutConfirmationScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC09F30B0E1010951952BDC /* SecureBackupLogoutConfirmationScreenUITests.swift */; }; F4433EF57B4BB3C077F8B00E /* SessionVerificationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD9E0FFA29EAACFF3AB9732 /* SessionVerificationScreenViewModel.swift */; }; F4971845B5C4F270F6BC5745 /* ScaledFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D82F234B3576BD6268C7950 /* ScaledFrameModifier.swift */; }; + F4996C82A4B3A5FF0C8EDD03 /* RoomListFilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */; }; F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */; }; F50A6FCE26714E27FE5495DD /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F23B21CF15F9F4BAA0788B /* PollOptionView.swift */; }; F519DE17A3A0F760307B2E6D /* InviteUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */; }; @@ -1565,6 +1567,7 @@ 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenViewModelProtocol.swift; sourceTree = ""; }; 897DF5E9A70CE05A632FC8AF /* UTType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTType.swift; sourceTree = ""; }; 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenUITests.swift; sourceTree = ""; }; + 8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersStateTests.swift; sourceTree = ""; }; 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainMentionBuilder.swift; sourceTree = ""; }; 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoader.swift; sourceTree = ""; }; 8BEBF0E59F25E842EDB6FD11 /* LocationSharingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenModels.swift; sourceTree = ""; }; @@ -1883,6 +1886,7 @@ DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationViewModelTests.swift; sourceTree = ""; }; DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineItem.swift; sourceTree = ""; }; E062C1750EFC8627DE4CAB8E /* MapTilerAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerAuthorization.swift; sourceTree = ""; }; + E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = ""; }; E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = ""; }; E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = ""; }; E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = ""; }; @@ -2130,6 +2134,7 @@ 037A5661B26EC6BE068188D7 /* Filters */ = { isa = PBXGroup; children = ( + E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */, E6372DD10DED30E7AD7BCE21 /* RoomListFiltersView.swift */, 24EC819497BB5F8C4998D760 /* RoomListFilterView.swift */, ); @@ -3343,6 +3348,7 @@ 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */, 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */, 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */, + 8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */, EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */, 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */, 58D295F0081084F38DB20893 /* RoomNotificationSettingsScreenViewModelTests.swift */, @@ -5328,6 +5334,7 @@ 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */, EA974337FA7D040E7C74FE6E /* RoomDetailsViewModelTests.swift in Sources */, 095D3906CF2F940C2D2D17CC /* RoomFlowCoordinatorTests.swift in Sources */, + 4C8C0C9FC10BA73AB7780534 /* RoomListFiltersStateTests.swift in Sources */, 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */, CAF8755E152204F55F8D6B5B /* RoomMembersListViewModelTests.swift in Sources */, E49F74BD93230BDEFFE5EA51 /* RoomNotificationSettingsScreenViewModelTests.swift in Sources */, @@ -5796,6 +5803,7 @@ 42F1C8731166633E35A6D7E6 /* RoomEventStringBuilder.swift in Sources */, D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */, 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */, + F4996C82A4B3A5FF0C8EDD03 /* RoomListFilterModels.swift in Sources */, 4A9CEEE612D6D8B3DDBD28BA /* RoomListFilterView.swift in Sources */, 33F1FB19F222BA9930AB1A00 /* RoomListFiltersView.swift in Sources */, FA4296218444C48BC890F46B /* RoomMemberDetails.swift in Sources */, @@ -6763,7 +6771,7 @@ repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 1.1.37; + version = 1.1.38; }; }; 821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ec0a7b921..a724ef7b27 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -129,8 +129,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "revision" : "4d0d75004f8361530d7424ab198e363027823718", - "version" : "1.1.37" + "revision" : "691d8b0f0994d9669fadbd2452bef7270f3713ad", + "version" : "1.1.38" } }, { diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift b/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift index 643eb198be..d151a443df 100644 --- a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift @@ -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: diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index a0f66d9051..f15070268d 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -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 - - init(enabledFilters: Set = []) { - 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) - } -} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 7a142c4563..2189b15ea2 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -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))) } } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift new file mode 100644 index 0000000000..cd52a7e227 --- /dev/null +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift @@ -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 + + init(activeFilters: Set = []) { + 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) + } +} diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift index e12d2cc563..41de9681aa 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift @@ -22,9 +22,9 @@ struct RoomListFilterView: View { var body: some View { let binding = Binding(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])) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift index 48d6c2cfa8..f46f130307 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift @@ -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])) } } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift index ae2ee78f7c..32ec813b48 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift @@ -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() diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 21c5957202..e93ecd517d 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -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:)) } diff --git a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift index 9c531efd69..338462de05 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift @@ -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) { } 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([]) } } } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift index 3f296137bd..c998ce2759 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift @@ -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)) } } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift index f33db5767e..f8a3350507 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift @@ -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 + + 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 = []) { + 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 { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index a60f19d7be..fad74117f9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -92,8 +92,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result { - 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) { diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 1d8196c03b..fa180c3ff9 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -25,10 +25,13 @@ class HomeScreenViewModelTests: XCTestCase { var clientProxy: MockClientProxy! var context: HomeScreenViewModelType.Context! { viewModel.context } var cancellables = Set() + var roomSummaryProvider: MockRoomSummaryProvider! override func setUpWithError() throws { + ServiceLocator.shared.settings.roomListFiltersEnabled = true cancellables.removeAll() - clientProxy = MockClientProxy(userID: "@mock:client.com") + roomSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms)) + clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: roomSummaryProvider) viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()), @@ -37,6 +40,10 @@ class HomeScreenViewModelTests: XCTestCase { userIndicatorController: ServiceLocator.shared.userIndicatorController) } + override func tearDown() { + AppSettings.reset() + } + func testSelectRoom() async throws { let mockRoomId = "mock_room_id" var correctResult = false @@ -153,4 +160,14 @@ class HomeScreenViewModelTests: XCTestCase { XCTAssertNil(context.alertInfo) XCTAssertTrue(correctResult) } + + func testFilters() async throws { + context.viewState.filtersState.activateFilter(.people) + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(roomSummaryProvider.currentFilter, RoomSummaryProviderFilter.include(.init(filters: [.people]))) + context.isSearchFieldFocused = true + context.searchQuery = "Test" + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(roomSummaryProvider.currentFilter, RoomSummaryProviderFilter.include(.init(query: "Test", filters: [.people]))) + } } diff --git a/UnitTests/Sources/RoomListFiltersStateTests.swift b/UnitTests/Sources/RoomListFiltersStateTests.swift new file mode 100644 index 0000000000..ca0629c5d4 --- /dev/null +++ b/UnitTests/Sources/RoomListFiltersStateTests.swift @@ -0,0 +1,70 @@ +// +// 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 XCTest + +@testable import ElementX + +final class RoomListFiltersStateTests: XCTestCase { + var state: RoomListFiltersState! + + override func setUp() { + state = RoomListFiltersState() + } + + func testInitialState() { + XCTAssertFalse(state.isFiltering) + XCTAssertEqual(state.activeFilters, []) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) + } + + func testSetAndUnsetFilters() { + state.activateFilter(.unreads) + XCTAssertTrue(state.isFiltering) + XCTAssertEqual(state.activeFilters, [.unreads]) + XCTAssertEqual(state.availableFilters, [.people, .rooms, .favourites]) + state.deactivateFilter(.unreads) + XCTAssertFalse(state.isFiltering) + XCTAssertEqual(state.activeFilters, []) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) + } + + func testMutuallyExclusiveFilters() { + state.activateFilter(.people) + XCTAssertTrue(state.isFiltering) + XCTAssertEqual(state.activeFilters, [.people]) + XCTAssertEqual(state.availableFilters, [.unreads, .favourites]) + state.deactivateFilter(.people) + state.activateFilter(.rooms) + state.activateFilter(.unreads) + XCTAssertTrue(state.isFiltering) + XCTAssertEqual(state.activeFilters, [.rooms, .unreads]) + XCTAssertEqual(state.availableFilters, [.favourites]) + } + + func testClearFilters() { + state.activateFilter(.people) + state.activateFilter(.unreads) + state.activateFilter(.favourites) + XCTAssertTrue(state.isFiltering) + XCTAssertEqual(state.activeFilters, [.people, .unreads, .favourites]) + XCTAssertEqual(state.availableFilters, []) + state.clearFilters() + XCTAssertFalse(state.isFiltering) + XCTAssertEqual(state.activeFilters, []) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) + } +} diff --git a/changelog.d/pr-2423.wip b/changelog.d/pr-2423.wip new file mode 100644 index 0000000000..c4ffd5a823 --- /dev/null +++ b/changelog.d/pr-2423.wip @@ -0,0 +1 @@ +All Filters have been implemented, except for the Favourites one. \ No newline at end of file diff --git a/project.yml b/project.yml index 1c0460c5a8..8fbfdad88a 100644 --- a/project.yml +++ b/project.yml @@ -47,7 +47,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/matrix-org/matrix-rust-components-swift - exactVersion: 1.1.37 + exactVersion: 1.1.38 # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios