Space filters (#4977)

* Add support for SpaceFilters on the SpaceServiceProxy level and a screen that renders them

* Add aliases to a couple of space room mocks

* Update translations and related snapshots

* Add a feature flag for space filters

* Add a new room summary provider filter that takes a list of room identifiers next to the normal state filters

This will take the SpaceFilter's descendants and combine it with the normal filters to reduce the rooms shown

* Add a new space filter button to the home screen and link it to the SpaceFiltersScreen and the actual RoomSummaryProvider room list filters

* Bump the RustSDK to v26.01.20-2 for SpaceFilter support.

* Add a cancellation button to the space filters screen

* Make the filter presentation mode liquidy, move the sheet to within the home screen.

* Address PR comments
This commit is contained in:
Stefan Ceriu
2026-01-21 22:19:39 +02:00
committed by GitHub
parent 0d34a28ec2
commit b4b9f6affa
41 changed files with 808 additions and 13 deletions
@@ -75,6 +75,14 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "CallNotificationRoomTimelineView_Previews")
}
func testChatsSpaceFilterCell() async throws {
try await performAccessibilityAudit(named: "ChatsSpaceFilterCell_Previews")
}
func testChatsSpaceFiltersScreen() async throws {
try await performAccessibilityAudit(named: "ChatsSpaceFiltersScreen_Previews")
}
func testCollapsibleRoomTimelineView() async throws {
try await performAccessibilityAudit(named: "CollapsibleRoomTimelineView_Previews")
}
+36
View File
@@ -355,6 +355,7 @@
3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; };
3DAD62988F072607441CB7A5 /* PollFormScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F8664F1FB95AF3C4202478 /* PollFormScreenCoordinator.swift */; };
3DAF325D8AE461F7CDB282BD /* StartChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6861FE915C7B5466E6962BBA /* StartChatScreen.swift */; };
3E2EE5782688054B3026EF8E /* ChatsSpaceFilterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847C893CF354BCBDF1351C13 /* ChatsSpaceFilterCell.swift */; };
3E3CC3D17908A3BB9F224CC5 /* DeactivateAccountScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67638D34FB6A9110BE481A6F /* DeactivateAccountScreenCoordinator.swift */; };
3E7B65C2C97748D5D65AAA8B /* NotificationPermissionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB2FC2AA9A07EE792DF65CF /* NotificationPermissionsScreenModels.swift */; };
3EC5A41F9FB7DD63A4DC6144 /* RoomChangeRolesScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14B1DE3E2D5D26732C49036 /* RoomChangeRolesScreenViewModel.swift */; };
@@ -800,6 +801,7 @@
89DF67AECBF9D0EE0DDB7737 /* Tracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B574805B9812C111D6215D /* Tracing.swift */; };
89E6426C6097F848C125E65C /* SpacesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD297970D7A0F8BAF870F010 /* SpacesScreenModels.swift */; };
8A0BD60CA4A6004DB06B5403 /* MediaUploadingPreprocessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669F35C505ACE1110589F875 /* MediaUploadingPreprocessor.swift */; };
8A2B2836B1B0CCA3CC140560 /* ChatsSpaceFiltersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F51637DA710BBE7B70D6D /* ChatsSpaceFiltersScreenViewModel.swift */; };
8A5064CAC8E5F3B18645621D /* CallNotificationRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6E082B0507FB28F966516A /* CallNotificationRoomTimelineView.swift */; };
8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */; };
8AA84EF202F2EFC8453A97BD /* SecureBackupRecoveryKeyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E027C112740573D27765C /* SecureBackupRecoveryKeyScreenModels.swift */; };
@@ -929,6 +931,7 @@
A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; };
A0601810597769B81C2358AF /* EncryptionResetPasswordScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */; };
A07178337F3C0B208B5A77A8 /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6ED50FE104992419310EEB /* NotificationHandler.swift */; };
A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */; };
A0861B727B273B5B3DD7FBF6 /* KnockRequestsListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */; };
A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */; };
A0D7E5BD0298A97DCBDCE40B /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; };
@@ -1216,6 +1219,7 @@
D46C33F8B61B55F0C8C2D15F /* LocationRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2AC540DE619B36832A5DB5 /* LocationRoomTimelineItem.swift */; };
D4CB979EB4FE26AAD9F9A72B /* UserProfileScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604A69C081B935D6A38DE6D8 /* UserProfileScreenModels.swift */; };
D4D7CCECC6C0AAFC42E165BB /* NotificationPermissionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9BBB18FB27F09032AD8769 /* NotificationPermissionsScreenViewModel.swift */; };
D4F3C5656DF09037825E9965 /* ChatsSpaceFiltersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF2C15634499348A512A93A /* ChatsSpaceFiltersScreenModels.swift */; };
D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */; };
D5681C80D8281560AACE0035 /* Label.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045253F9967A535EE5B16691 /* Label.swift */; };
D5B1531A72387D432939D4E0 /* RoomDirectorySearchProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0516C69708D5CBDE1A8E77EC /* RoomDirectorySearchProxyProtocol.swift */; };
@@ -1304,6 +1308,7 @@
E67418DACEDBC29E988E6ACD /* message.caf in Resources */ = {isa = PBXBuildFile; fileRef = ED482057AE39D5C6D9C5F3D8 /* message.caf */; };
E6A2F6E4795C1054025AACFF /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3AB32690C6CE5C62A86D6FA /* NotificationItemProxy.swift */; };
E6FA87F773424B27614B23E9 /* TimelineItemAccessibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7E93C2E148B96EF6A8500 /* TimelineItemAccessibilityModifier.swift */; };
E713F930C5AD0F4B11119E2B /* ChatsSpaceFiltersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 154664B1B0D71846FBAC6E61 /* ChatsSpaceFiltersScreen.swift */; };
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */; };
E77FE06B165A38BF1735509F /* SecureBackupScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF73F49E6B6683F7E2D26F0 /* SecureBackupScreenCoordinator.swift */; };
E78D429F18071545BF661A52 /* RoomDetailsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */; };
@@ -1668,6 +1673,7 @@
14517E5597594956FCE1950D /* RoomInfoProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInfoProxyProtocol.swift; sourceTree = "<group>"; };
1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenModels.swift; sourceTree = "<group>"; };
1511B1DCECC0DC75EB267328 /* KnockRequestsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreen.swift; sourceTree = "<group>"; };
154664B1B0D71846FBAC6E61 /* ChatsSpaceFiltersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFiltersScreen.swift; sourceTree = "<group>"; };
1562EAF6231151A675BED7A9 /* RoomDirectorySearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenCoordinator.swift; sourceTree = "<group>"; };
15748C254911E3654C93B0ED /* MentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBuilder.swift; sourceTree = "<group>"; };
1575947B7A6FE08C57FE5EE4 /* NetworkMonitorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitorProtocol.swift; sourceTree = "<group>"; };
@@ -2261,6 +2267,7 @@
840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItemProxyMock.swift; sourceTree = "<group>"; };
84311D707B09854D67F78BBF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
845DDBDE5A0887E73D38B826 /* InviteUsersViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersViewModelTests.swift; sourceTree = "<group>"; };
847C893CF354BCBDF1351C13 /* ChatsSpaceFilterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFilterCell.swift; sourceTree = "<group>"; };
848F69921527D31CAACB93AF /* SecureBackupLogoutConfirmationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenViewModelTests.swift; sourceTree = "<group>"; };
84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreen.swift; sourceTree = "<group>"; };
84A87D0471D438A233C2CF4A /* RoomMemberDetailsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModel.swift; sourceTree = "<group>"; };
@@ -2275,6 +2282,7 @@
85666E40F7E817809B4FD787 /* ComposerToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbar.swift; sourceTree = "<group>"; };
8585C636A10B8141A7AE909F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = "<group>"; };
858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockedRoomProxy.swift; sourceTree = "<group>"; };
859F51637DA710BBE7B70D6D /* ChatsSpaceFiltersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFiltersScreenViewModel.swift; sourceTree = "<group>"; };
85A1941B874A3BE9CDDF43EF /* XCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCase.swift; sourceTree = "<group>"; };
85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = "<group>"; };
8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
@@ -2497,6 +2505,7 @@
AE739A6836E86E3780748477 /* TimelineItemBubbleBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbleBackground.swift; sourceTree = "<group>"; };
AEB5FF7A09B79B0C6B528F7C /* SFNumberedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFNumberedListView.swift; sourceTree = "<group>"; };
AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilderTests.swift; sourceTree = "<group>"; };
AEF2C15634499348A512A93A /* ChatsSpaceFiltersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFiltersScreenModels.swift; sourceTree = "<group>"; };
AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderTests.swift; sourceTree = "<group>"; };
AF2E6ADAE685F4109B1FE795 /* TimelineThreadSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineThreadSummaryView.swift; sourceTree = "<group>"; };
AF848B41DAF1066F3054D4A1 /* SessionVerificationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenModels.swift; sourceTree = "<group>"; };
@@ -2792,6 +2801,7 @@
E5FDFAA04174CC99FB66391C /* EditRoomAddressScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModel.swift; sourceTree = "<group>"; };
E604A74AEFE0BF6151061291 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E60757AFE04391B43EA568B8 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFiltersScreenViewModelProtocol.swift; sourceTree = "<group>"; };
E6372DD10DED30E7AD7BCE21 /* RoomListFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersView.swift; sourceTree = "<group>"; };
E66763BD54A3A1D9C6E6F2F1 /* PinnedItemsIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemsIndicatorView.swift; sourceTree = "<group>"; };
E6935A55AB3B0C94BC566DD6 /* EncryptionResetPasswordScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -3724,6 +3734,17 @@
path = Views;
sourceTree = "<group>";
};
330B6E4BB92EA4AE6A95828E /* ChatsSpaceFiltersScreen */ = {
isa = PBXGroup;
children = (
AEF2C15634499348A512A93A /* ChatsSpaceFiltersScreenModels.swift */,
859F51637DA710BBE7B70D6D /* ChatsSpaceFiltersScreenViewModel.swift */,
E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */,
82F77DDA38D41A185D3536F4 /* View */,
);
path = ChatsSpaceFiltersScreen;
sourceTree = "<group>";
};
3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */ = {
isa = PBXGroup;
children = (
@@ -5137,6 +5158,15 @@
path = Session;
sourceTree = "<group>";
};
82F77DDA38D41A185D3536F4 /* View */ = {
isa = PBXGroup;
children = (
847C893CF354BCBDF1351C13 /* ChatsSpaceFilterCell.swift */,
154664B1B0D71846FBAC6E61 /* ChatsSpaceFiltersScreen.swift */,
);
path = View;
sourceTree = "<group>";
};
832EB453B3A5D04C18D86117 /* View */ = {
isa = PBXGroup;
children = (
@@ -6427,6 +6457,7 @@
EFD4F7FCAAAB3EF45EE7A067 /* BlockedUsersScreen */,
53FB148CD26AFB6A5B9E20B3 /* BugReportScreen */,
1185EECDD07495D65AC84AFC /* CallScreen */,
330B6E4BB92EA4AE6A95828E /* ChatsSpaceFiltersScreen */,
90DC2E28718955ED87AD1456 /* CreatePollScreen */,
821EB0D1C0019E3C7BBAEDBB /* CreateRoomScreen */,
3E1CCC4B607946CE90B4A827 /* DeclineAndBlockScreen */,
@@ -7934,6 +7965,11 @@
E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */,
DF8F1211F2B0B56F0FCCA5C2 /* CertificateValidatorHook.swift in Sources */,
D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */,
3E2EE5782688054B3026EF8E /* ChatsSpaceFilterCell.swift in Sources */,
E713F930C5AD0F4B11119E2B /* ChatsSpaceFiltersScreen.swift in Sources */,
D4F3C5656DF09037825E9965 /* ChatsSpaceFiltersScreenModels.swift in Sources */,
8A2B2836B1B0CCA3CC140560 /* ChatsSpaceFiltersScreenViewModel.swift in Sources */,
A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */,
572474C7CA4B03FF0B5DF548 /* ChatsTabFlowCoordinator.swift in Sources */,
BC5F94B10B40ABEC6046B473 /* ChatsTabFlowCoordinatorStateMachine.swift in Sources */,
A52090A4FE0DB826578DFC03 /* Client.swift in Sources */,
@@ -541,10 +541,14 @@
"screen_create_room_public_option_short_description" = "Anyone can join.";
"screen_create_room_room_access_section_knocking_option_description" = "Anyone can ask to join but an administrator or a moderator must accept the request.";
"screen_create_room_room_access_section_knocking_option_title" = "Allow ask to join";
"screen_create_room_room_access_section_knocking_restricted_option_description" = "Anyone in %1$@ can join but everyone else must request access.";
"screen_create_room_room_access_section_knocking_restricted_option_title" = "Ask to join";
"screen_create_room_room_access_section_private_option_description" = "Only people invited can join.";
"screen_create_room_room_access_section_private_option_title" = "Private";
"screen_create_room_room_access_section_public_option_description" = "Anyone can join.";
"screen_create_room_room_access_section_public_option_title" = "Public";
"screen_create_room_room_access_section_restricted_option_description" = "Anyone in %1$@ can join.";
"screen_create_room_room_access_section_restricted_option_title" = "Standard";
"screen_create_room_room_access_section_title" = "Who has access";
"screen_create_room_room_address_section_footer" = "You’ll need an address in order to make it visible in the public directory.";
"screen_create_room_room_address_section_title" = "Address";
@@ -541,10 +541,14 @@
"screen_create_room_public_option_short_description" = "Anyone can join.";
"screen_create_room_room_access_section_knocking_option_description" = "Anyone can ask to join but an administrator or a moderator must accept the request.";
"screen_create_room_room_access_section_knocking_option_title" = "Allow ask to join";
"screen_create_room_room_access_section_knocking_restricted_option_description" = "Anyone in %1$@ can join but everyone else must request access.";
"screen_create_room_room_access_section_knocking_restricted_option_title" = "Ask to join";
"screen_create_room_room_access_section_private_option_description" = "Only people invited can join.";
"screen_create_room_room_access_section_private_option_title" = "Private";
"screen_create_room_room_access_section_public_option_description" = "Anyone can join.";
"screen_create_room_room_access_section_public_option_title" = "Public";
"screen_create_room_room_access_section_restricted_option_description" = "Anyone in %1$@ can join.";
"screen_create_room_room_access_section_restricted_option_title" = "Standard";
"screen_create_room_room_access_section_title" = "Who has access";
"screen_create_room_room_address_section_footer" = "You’ll need an address in order to make it visible in the public directory.";
"screen_create_room_room_address_section_title" = "Address";
@@ -67,6 +67,7 @@ final class AppSettings {
case linkPreviewsEnabled
case focusEventOnNotificationTap
case linkNewDeviceEnabled
case spaceFiltersEnabled
// Spaces
case spaceSettingsEnabled
@@ -420,6 +421,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.linkNewDeviceEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkNewDeviceEnabled
@UserPreference(key: UserDefaultsKeys.spaceFiltersEnabled, defaultValue: false, storageType: .userDefaults(store))
var spaceFiltersEnabled
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: isDevelopmentBuild, storageType: .userDefaults(store))
var developerOptionsEnabled
}
@@ -52,7 +52,7 @@ class ChatsTabFlowCoordinatorStateMachine {
case declineAndBlockUserScreen(detailState: DetailState?)
/// The selected room ID from the state if available.
/// The state of the currently selected room
var detailState: DetailState? {
switch self {
case .initial, .userProfileScreen, .shareExtensionRoomList:
+12
View File
@@ -1606,6 +1606,12 @@ internal enum L10n {
internal static var screenCreateRoomRoomAccessSectionKnockingOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_option_description") }
/// Allow ask to join
internal static var screenCreateRoomRoomAccessSectionKnockingOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_option_title") }
/// Anyone in %1$@ can join but everyone else must request access.
internal static func screenCreateRoomRoomAccessSectionKnockingRestrictedOptionDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_restricted_option_description", String(describing: p1))
}
/// Ask to join
internal static var screenCreateRoomRoomAccessSectionKnockingRestrictedOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_restricted_option_title") }
/// Only people invited can join.
internal static var screenCreateRoomRoomAccessSectionPrivateOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_private_option_description") }
/// Private
@@ -1614,6 +1620,12 @@ internal enum L10n {
internal static var screenCreateRoomRoomAccessSectionPublicOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_public_option_description") }
/// Public
internal static var screenCreateRoomRoomAccessSectionPublicOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_public_option_title") }
/// Anyone in %1$@ can join.
internal static func screenCreateRoomRoomAccessSectionRestrictedOptionDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_create_room_room_access_section_restricted_option_description", String(describing: p1))
}
/// Standard
internal static var screenCreateRoomRoomAccessSectionRestrictedOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_restricted_option_title") }
/// Who has access
internal static var screenCreateRoomRoomAccessSectionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_title") }
/// You’ll need an address in order to make it visible in the public directory.
@@ -16814,6 +16814,11 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable {
set(value) { underlyingTopLevelSpacesPublisher = value }
}
var underlyingTopLevelSpacesPublisher: CurrentValuePublisher<[SpaceServiceRoomProtocol], Never>!
var spaceFilterPublisher: CurrentValuePublisher<[SpaceServiceFilter], Never> {
get { return underlyingSpaceFilterPublisher }
set(value) { underlyingSpaceFilterPublisher = value }
}
var underlyingSpaceFilterPublisher: CurrentValuePublisher<[SpaceServiceFilter], Never>!
//MARK: - spaceRoomList
@@ -51,7 +51,7 @@ extension RoomSummaryProviderMock {
}
roomListSubject.send(rooms)
case let .all(filters):
case let .rooms(_, filters), let .all(filters):
var rooms = initialRooms
if filters.count > 1 {
@@ -60,6 +60,7 @@ extension [SpaceServiceRoomProtocol] {
isSpace: true,
childrenCount: 1,
joinedMembersCount: 500,
canonicalAlias: "#the-foundation:matrix.org",
state: .joined)),
SpaceServiceRoomMock(.init(id: "space2",
name: "The Second Foundation",
@@ -72,6 +73,7 @@ extension [SpaceServiceRoomProtocol] {
isSpace: true,
childrenCount: 25000,
joinedMembersCount: 1_000_000_000,
canonicalAlias: "#the-galactic-empire:matrix.org",
state: .joined)),
SpaceServiceRoomMock(.init(id: "space4",
name: "The Korellians",
@@ -14,6 +14,7 @@ import MatrixRustSDKMocks
extension SpaceServiceProxyMock {
struct Configuration {
var topLevelSpaces: [SpaceServiceRoomProtocol] = []
var spaceFilters: [SpaceServiceFilter] = []
var joinedParentSpaces: [SpaceServiceRoomProtocol] = []
var spaceRoomLists: [String: SpaceRoomListProxyMock] = [:]
var leaveSpaceRooms: [LeaveSpaceRoom] = []
@@ -23,6 +24,8 @@ extension SpaceServiceProxyMock {
self.init()
topLevelSpacesPublisher = .init(configuration.topLevelSpaces)
spaceFilterPublisher = .init(configuration.spaceFilters)
joinedParentsChildIDReturnValue = .success(configuration.joinedParentSpaces)
spaceRoomListSpaceIDClosure = { spaceID in
if let spaceRoomList = configuration.spaceRoomLists[spaceID] {
@@ -31,6 +34,7 @@ extension SpaceServiceProxyMock {
.failure(.sdkError(ClientProxyMockError.generic))
}
}
leaveSpaceSpaceIDClosure = { spaceID in
.success(LeaveSpaceHandleProxy(spaceID: spaceID,
leaveHandle: LeaveSpaceHandleSDKMock(.init(rooms: configuration.leaveSpaceRooms))))
@@ -45,13 +49,21 @@ extension SpaceServiceProxyMock {
extension SpaceServiceProxyMock.Configuration {
static var populated: SpaceServiceProxyMock.Configuration {
let spaceFilters = [SpaceServiceRoomProtocol].mockJoinedSpaces.reduce(into: [SpaceServiceFilter]()) { partialResult, spaceRoom in
partialResult.append(SpaceServiceFilter(room: spaceRoom, level: 0, descendants: .init()))
partialResult.append(SpaceServiceFilter(room: spaceRoom, level: 1, descendants: .init()))
}
let spaceRoomLists = [SpaceServiceRoomProtocol].mockJoinedSpaces.map {
($0.id, SpaceRoomListProxyMock(.init(spaceServiceRoom: $0, initialSpaceRooms: .mockSpaceList)))
}
let subSpaceRoomLists = [SpaceServiceRoomProtocol].mockSpaceList.map {
($0.id, SpaceRoomListProxyMock(.init(spaceServiceRoom: $0, initialSpaceRooms: .mockSingleRoom)))
}
return .init(topLevelSpaces: .mockJoinedSpaces, spaceRoomLists: .init(uniqueKeysWithValues: spaceRoomLists + subSpaceRoomLists))
return .init(topLevelSpaces: .mockJoinedSpaces,
spaceFilters: spaceFilters,
spaceRoomLists: .init(uniqueKeysWithValues: spaceRoomLists + subSpaceRoomLists))
}
}
@@ -108,6 +108,7 @@ enum A11yIdentifiers {
let userAvatar = "home_screen-user_avatar"
let recoveryKeyConfirmationBannerContinue = "home_screen-recovery_key_confirmation_continue"
let startChat = "home_screen-start_chat"
let spaceFilters = "home_screen-space_filters"
let roomNamePrefix = "home_screen-room_name"
func roomName(_ name: String) -> String {
+2 -1
View File
@@ -141,6 +141,7 @@ enum RoomAvatarSizeOnScreen {
case chats
case spaces
case spaceSettings
case spaceFilters
case authorizedSpaces
case timeline
case leaveSpace
@@ -161,7 +162,7 @@ enum RoomAvatarSizeOnScreen {
case .chats, .spaces, .spaceSettings:
return 52
case .timeline, .leaveSpace, .roomDirectorySearch,
.completionSuggestions, .authorizedSpaces:
.completionSuggestions, .authorizedSpaces, .spaceFilters:
return 32
case .notificationSettings:
return 30
+4
View File
@@ -113,6 +113,10 @@ extension SDKListener: SpaceRoomListSpaceListener where T == SpaceRoom? {
func onUpdate(space: SpaceRoom?) { onUpdateClosure(space) }
}
extension SDKListener: SpaceServiceSpaceFiltersListener where T == [SpaceFilterUpdate] {
func onUpdate(filterUpdates: [SpaceFilterUpdate]) { onUpdateClosure(filterUpdates) }
}
// MARK: Room
extension SDKListener: RoomInfoListener where T == RoomInfo {
@@ -26,6 +26,8 @@ enum TestablePreviewsDictionary {
"BugReportScreen_Previews" : BugReportScreen_Previews.self,
"CallInviteRoomTimelineView_Previews" : CallInviteRoomTimelineView_Previews.self,
"CallNotificationRoomTimelineView_Previews" : CallNotificationRoomTimelineView_Previews.self,
"ChatsSpaceFilterCell_Previews" : ChatsSpaceFilterCell_Previews.self,
"ChatsSpaceFiltersScreen_Previews" : ChatsSpaceFiltersScreen_Previews.self,
"CollapsibleRoomTimelineView_Previews" : CollapsibleRoomTimelineView_Previews.self,
"CompletionSuggestion_Previews" : CompletionSuggestion_Previews.self,
"ComposerToolbar_Previews" : ComposerToolbar_Previews.self,
@@ -0,0 +1,32 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
enum ChatsSpaceFiltersScreenViewModelAction {
case confirm(SpaceServiceFilter)
case cancel
}
struct ChatsSpaceFiltersScreenViewState: BindableState {
var filters = [SpaceServiceFilter]()
var bindings: ChatsSpaceFiltersScreenViewStateBindings
}
struct ChatsSpaceFiltersScreenViewStateBindings { }
enum ChatsSpaceFiltersScreenViewAction: CustomStringConvertible {
case confirm(SpaceServiceFilter)
case cancel
var description: String {
switch self {
case .confirm: "Confirm"
case .cancel: "Cancel"
}
}
}
@@ -0,0 +1,50 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias ChatsSpaceFiltersScreenViewModelType = StateStoreViewModelV2<ChatsSpaceFiltersScreenViewState, ChatsSpaceFiltersScreenViewAction>
class ChatsSpaceFiltersScreenViewModel: ChatsSpaceFiltersScreenViewModelType, ChatsSpaceFiltersScreenViewModelProtocol, Identifiable {
private let spaceService: SpaceServiceProxyProtocol
private let actionsSubject: PassthroughSubject<ChatsSpaceFiltersScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ChatsSpaceFiltersScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
let id = UUID()
init(spaceService: SpaceServiceProxyProtocol,
mediaProvider: MediaProviderProtocol) {
self.spaceService = spaceService
super.init(initialViewState: ChatsSpaceFiltersScreenViewState(bindings: .init()),
mediaProvider: mediaProvider)
state.filters = spaceService.spaceFilterPublisher.value
spaceService.spaceFilterPublisher.sink { [weak self] filters in
self?.state.filters = filters
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: ChatsSpaceFiltersScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .confirm(let filter):
actionsSubject.send(.confirm(filter))
case .cancel:
actionsSubject.send(.cancel)
}
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@MainActor
protocol ChatsSpaceFiltersScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<ChatsSpaceFiltersScreenViewModelAction, Never> { get }
var context: ChatsSpaceFiltersScreenViewModelType.Context { get }
}
@@ -0,0 +1,104 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
struct ChatsSpaceFilterCell: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let filter: SpaceServiceFilter
let mediaProvider: MediaProviderProtocol!
private let verticalInsets = 12.0
private let horizontalInsets = 16.0
let action: (SpaceServiceFilter) -> Void
var body: some View {
Button {
action(filter)
} label: {
HStack(spacing: 12.0) {
HStack(spacing: 8.0) {
if filter.level > 0 {
Spacer(minLength: 16 * CGFloat(filter.level))
}
HStack(spacing: 12.0) {
avatar
content
.padding(.vertical, verticalInsets)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: 1 / UIScreen.main.scale)
.padding(.trailing, -horizontalInsets)
}
}
.accessibilityElement(children: .combine)
}
}
}
.padding(.horizontal, horizontalInsets)
}
@ViewBuilder @MainActor
private var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: filter.room.avatar,
avatarSize: .room(on: .spaceFilters),
mediaProvider: mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
}
private var content: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text(filter.room.name)
.font(.compound.bodyLG)
.foregroundColor(.compound.textPrimary)
.lineLimit(1)
ZStack {
// Hidden text to maintain consistent height.
Text("")
.hidden()
if let alias = filter.room.canonicalAlias {
Text(alias)
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct ChatsSpaceFilterCell_Previews: PreviewProvider, TestablePreview {
static let mediaProvider = MediaProviderMock(configuration: .init())
static let spaces = [SpaceServiceRoomProtocol].mockJoinedSpaces2
static var previews: some View {
VStack(spacing: 0) {
ForEach(spaces, id: \.id) { space in
ChatsSpaceFilterCell(filter: .init(room: space, level: 0, descendants: .init()),
mediaProvider: mediaProvider) { _ in }
ChatsSpaceFilterCell(filter: .init(room: space, level: 1, descendants: .init()),
mediaProvider: mediaProvider) { _ in }
}
}
}
}
@@ -0,0 +1,56 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct ChatsSpaceFiltersScreen: View {
@Bindable var context: ChatsSpaceFiltersScreenViewModel.Context
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(context.viewState.filters) { filter in
ChatsSpaceFilterCell(filter: filter,
mediaProvider: context.mediaProvider) { filter in
context.send(viewAction: .confirm(filter))
}
}
}
}
.toolbar { toolbar }
.navigationTitle(L10n.screenRoomlistYourSpaces)
.navigationBarTitleDisplayMode(.inline)
}
.presentationDragIndicator(.visible)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
ToolbarButton(role: .cancel) {
context.send(viewAction: .cancel)
}
}
}
}
// MARK: - Previews
struct ChatsSpaceFiltersScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static var previews: some View {
ChatsSpaceFiltersScreen(context: viewModel.context)
}
static func makeViewModel() -> ChatsSpaceFiltersScreenViewModel {
ChatsSpaceFiltersScreenViewModel(spaceService: SpaceServiceProxyMock(.populated),
mediaProvider: MediaProviderMock(configuration: .init()))
}
}
@@ -43,6 +43,7 @@ enum HomeScreenViewAction {
case dismissNewSoundBanner
case updateVisibleItemRange(Range<Int>)
case globalSearch
case spaceFilters
case markRoomAsUnread(roomIdentifier: String)
case markRoomAsRead(roomIdentifier: String)
case markRoomAsFavourite(roomIdentifier: String, isFavourite: Bool)
@@ -109,6 +110,10 @@ struct HomeScreenViewState: BindableState {
var reportRoomEnabled = false
var spaceFiltersEnabled = false
var selectedSpaceFilter: SpaceServiceFilter?
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
return placeholderRooms
@@ -150,6 +155,8 @@ struct HomeScreenViewStateBindings {
var alertInfo: AlertInfo<UUID>?
var leaveRoomAlertItem: LeaveRoomAlertItem?
var spaceFiltersViewModel: ChatsSpaceFiltersScreenViewModel?
}
struct HomeScreenRoom: Identifiable, Equatable {
@@ -15,6 +15,7 @@ typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, Hom
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let userSession: UserSessionProtocol
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
private let analyticsService: AnalyticsService
private let appSettings: AppSettings
private let notificationManager: NotificationManagerProtocol
@@ -39,9 +40,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
self.notificationManager = notificationManager
self.userIndicatorController = userIndicatorController
spaceFilterSubject = CurrentValueSubject<SpaceServiceFilter?, Never>(nil)
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
super.init(initialViewState: .init(userID: userSession.clientProxy.userID,
spaceFiltersEnabled: appSettings.spaceFiltersEnabled,
bindings: .init(filtersState: .init(appSettings: appSettings))),
mediaProvider: userSession.mediaProvider)
@@ -112,12 +116,22 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
.store(in: &cancellables)
appSettings.$spaceFiltersEnabled
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.spaceFiltersEnabled, on: self)
.store(in: &cancellables)
userSession.clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
spaceFilterSubject
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.selectedSpaceFilter, on: self)
.store(in: &cancellables)
Task {
state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported
}
@@ -126,9 +140,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
let activeFilters = context.$viewState.map(\.bindings.filtersState.activeFilters)
isSearchFieldFocused
.combineLatest(searchQuery, activeFilters)
.combineLatest(searchQuery, activeFilters, spaceFilterSubject)
.removeDuplicates { $0 == $1 }
.sink { [weak self] isSearchFieldFocused, _, _ in
.sink { [weak self] isSearchFieldFocused, _, _, _ in
guard let self else { return }
// isSearchFieldFocused` is sometimes turning to true after cancelling the search. So to be extra sure we are updating the values correctly we read them directly in the next run loop, and we add a small delay if the value has changed
let delay = isSearchFieldFocused == self.context.viewState.bindings.isSearchFieldFocused ? 0.0 : 0.05
@@ -175,6 +189,26 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
actionsSubject.send(.presentStartChatScreen)
case .globalSearch:
actionsSubject.send(.presentGlobalSearch)
case .spaceFilters:
if spaceFilterSubject.value != nil {
spaceFilterSubject.send(nil)
} else {
state.bindings.spaceFiltersViewModel = ChatsSpaceFiltersScreenViewModel(spaceService: userSession.clientProxy.spaceService,
mediaProvider: userSession.mediaProvider)
state.bindings.spaceFiltersViewModel?.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .confirm(let spaceServiceFilter):
spaceFilterSubject.send(spaceServiceFilter)
state.bindings.spaceFiltersViewModel = nil
case .cancel:
state.bindings.spaceFiltersViewModel = nil
}
}
.store(in: &cancellables)
}
case .markRoomAsUnread(let roomIdentifier):
Task {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
@@ -242,7 +276,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
if state.bindings.isSearchFieldFocused {
roomSummaryProvider?.setFilter(.search(query: state.bindings.searchQuery))
} else {
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
if let spaceFilter = spaceFilterSubject.value {
roomSummaryProvider?.setFilter(.rooms(roomsIDs: spaceFilter.descendants,
filters: state.bindings.filtersState.activeFilters.set))
} else {
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
}
}
}
}
@@ -16,22 +16,40 @@ struct HomeScreen: View {
@State private var scrollViewAdapter = ScrollViewAdapter()
@Namespace private var navigationTransitionNamespace
private enum NavigationTransitionSourceID {
case spaceFilters
}
var body: some View {
HomeScreenContent(context: context, scrollViewAdapter: scrollViewAdapter)
.alert(item: $context.alertInfo)
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
.navigationTitle(title)
.toolbar { toolbar }
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.track(screen: .Home)
.toolbarBloom(hasSearchBar: true)
.sentryTrace("\(Self.self)")
.sheet(item: $context.spaceFiltersViewModel) { vm in
ChatsSpaceFiltersScreen(context: vm.context)
.navigationTransition(.zoom(sourceID: NavigationTransitionSourceID.spaceFilters,
in: navigationTransitionNamespace))
}
}
// MARK: - Private
private var title: String {
if let selectedSpace = context.viewState.selectedSpaceFilter {
selectedSpace.room.name
} else {
L10n.screenRoomlistMainSpaceTitle
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
@@ -47,6 +65,20 @@ struct HomeScreen: View {
.buttonStyle(.compound(.super, size: .toolbarIcon))
}
}
if context.viewState.spaceFiltersEnabled {
if #available(iOS 26, *) {
ToolbarSpacer(.fixed, placement: .primaryAction)
}
ToolbarItem(placement: .primaryAction) {
SpaceFiltersButton(selected: context.viewState.selectedSpaceFilter != nil) {
context.send(viewAction: .spaceFilters)
}
.matchedTransitionSource(id: NavigationTransitionSourceID.spaceFilters,
in: navigationTransitionNamespace)
}
}
}
private var settingsButton: some View {
@@ -93,6 +125,42 @@ struct HomeScreen: View {
private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View {
Text(item.subtitle)
}
private struct SpaceFiltersButton: View {
var selected = false
var action: () -> Void
var body: some View {
if #available(iOS 26, *) {
if selected {
content
.backportButtonStyleGlassProminent()
.tint(.compound.bgAccentRest)
} else {
content
}
} else {
if selected {
content
.buttonStyle(.compound(.primary, size: .toolbarIcon))
} else {
content
.buttonStyle(.compound(.tertiary, size: .toolbarIcon))
}
}
}
private var content: some View {
Button {
action()
} label: {
CompoundIcon(\.filter)
}
.accessibilityLabel(L10n.screenRoomlistYourSpaces)
.accessibilityAddTraits(selected ? .isSelected : [])
.accessibilityIdentifier(A11yIdentifiers.homeScreen.spaceFilters)
}
}
}
// MARK: - Previews
@@ -70,6 +70,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var spaceSettingsEnabled: Bool { get set }
var createSpaceEnabled: Bool { get set }
var spaceFiltersEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -52,6 +52,10 @@ struct DeveloperOptionsScreen: View {
}
Section("Spaces") {
Toggle(isOn: $context.spaceFiltersEnabled) {
Text("Space filters")
}
Toggle(isOn: $context.spaceSettingsEnabled) {
Text("Space settings")
}
@@ -133,6 +133,18 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
[.normalizedMatchRoomName(pattern: query)] + baseFilter
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: filters))
case .rooms(let roomIDs, let filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter
if !roomIDs.isEmpty {
rustFilters.append(.identifiers(identifiers: Array(roomIDs)))
}
if !filters.contains(.lowPriority), appSettings.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority)
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: rustFilters))
case let .all(filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter
@@ -40,6 +40,8 @@ enum RoomSummaryProviderFilter: Equatable {
case search(query: String)
/// Includes only what satisfies the filters used
case all(filters: Set<RoomListFilter>)
/// Include only rooms from the given that satisfy the given filters
case rooms(roomsIDs: Set<String>, filters: Set<RoomListFilter>)
}
// sourcery: AutoMockable
@@ -19,6 +19,12 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
spacesSubject.asCurrentValuePublisher()
}
private var spaceFilterHandle: TaskHandle?
private let spaceFilterSubject = CurrentValueSubject<[SpaceServiceFilter], Never>([])
var spaceFilterPublisher: CurrentValuePublisher<[SpaceServiceFilter], Never> {
spaceFilterSubject.asCurrentValuePublisher()
}
init(spaceService: SpaceServiceProtocol) {
self.spaceService = spaceService
@@ -27,7 +33,11 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
private func setupSubscriptions() async {
topLevelSpacesHandle = await spaceService.subscribeToTopLevelJoinedSpaces(listener: SDKListener { [weak self] updates in
self?.handleUpdates(updates)
self?.handleSpaceListUpdates(updates)
})
spaceFilterHandle = await spaceService.subscribeToSpaceFilters(listener: SDKListener { [weak self] updates in
self?.handleSpaceFilterUpdates(updates)
})
}
@@ -87,7 +97,7 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
// MARK: - Private
private func handleUpdates(_ updates: [SpaceListUpdate]) {
private func handleSpaceListUpdates(_ updates: [SpaceListUpdate]) {
var spaces = spacesSubject.value
for update in updates {
@@ -119,4 +129,37 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
spacesSubject.send(spaces)
}
private func handleSpaceFilterUpdates(_ updates: [SpaceFilterUpdate]) {
var filters = spaceFilterSubject.value
for update in updates {
switch update {
case .append(let spaceFilters):
filters.append(contentsOf: spaceFilters.map(SpaceServiceFilter.init))
case .clear:
filters.removeAll()
case .pushFront(let filter):
filters.insert(SpaceServiceFilter(filter: filter), at: 0)
case .pushBack(let filter):
filters.append(SpaceServiceFilter(filter: filter))
case .popFront:
filters.removeFirst()
case .popBack:
filters.removeLast()
case .insert(let index, let filter):
filters.insert(SpaceServiceFilter(filter: filter), at: Int(index))
case .set(let index, let filter):
filters[Int(index)] = SpaceServiceFilter(filter: filter)
case .remove(let index):
filters.remove(at: Int(index))
case .truncate(let length):
filters.removeSubrange(Int(length)..<filters.count)
case .reset(let spaceFilters):
filters = spaceFilters.map(SpaceServiceFilter.init)
}
}
spaceFilterSubject.send(filters)
}
}
@@ -7,15 +7,44 @@
//
import Foundation
import MatrixRustSDK
enum SpaceServiceProxyError: Error {
case sdkError(Error)
case missingSpace
}
struct SpaceServiceFilter: Identifiable, Equatable {
let room: SpaceServiceRoomProtocol
let level: UInt
let descendants: Set<String>
init(room: SpaceServiceRoomProtocol, level: UInt, descendants: Set<String>) {
self.room = room
self.level = level
self.descendants = descendants
}
init(filter: SpaceFilter) {
room = SpaceServiceRoom(spaceRoom: filter.spaceRoom)
level = UInt(max(filter.level, 0))
descendants = Set(filter.descendants)
}
// Same rooms might appear on multiple levels
var id: String {
room.id + "\(level)"
}
static func == (lhs: SpaceServiceFilter, rhs: SpaceServiceFilter) -> Bool {
lhs.room.id == rhs.room.id
}
}
// sourcery: AutoMockable
protocol SpaceServiceProxyProtocol {
var topLevelSpacesPublisher: CurrentValuePublisher<[SpaceServiceRoomProtocol], Never> { get }
var spaceFilterPublisher: CurrentValuePublisher<[SpaceServiceFilter], Never> { get }
func spaceRoomList(spaceID: String) async -> Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>
/// Returns a joined space given its identifier
@@ -130,6 +130,20 @@ extension PreviewTests {
}
}
func testChatsSpaceFilterCell() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
for (index, preview) in ChatsSpaceFilterCell_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testChatsSpaceFiltersScreen() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
for (index, preview) in ChatsSpaceFiltersScreen_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testCollapsibleRoomTimelineView() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
for (index, preview) in CollapsibleRoomTimelineView_Previews._allPreviews.enumerated() {
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d3f226d34b1e0bd2297f4359189c8b33223316ea7dfb685bc6de866bb58e5ff
size 142344
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d3f226d34b1e0bd2297f4359189c8b33223316ea7dfb685bc6de866bb58e5ff
size 142344
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8abbfad872d996a2f82668373b92f0f33ec51b637263deb14c655d0cb958606
size 93320
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8abbfad872d996a2f82668373b92f0f33ec51b637263deb14c655d0cb958606
size 93320
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4bb60e28bcde15e69c627fa4b5f6dca2b26a3f25cbf1afb62a9302d6d549b46
size 150715
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4bb60e28bcde15e69c627fa4b5f6dca2b26a3f25cbf1afb62a9302d6d549b46
size 150715
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e28fb24e8a9087f8e951ed8c18ab9048cfacd72fcb2c328ddf3e46169306a1b6
size 100972
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e28fb24e8a9087f8e951ed8c18ab9048cfacd72fcb2c328ddf3e46169306a1b6
size 100972
@@ -17125,6 +17125,52 @@ open class RoomSDKMock: MatrixRustSDK.Room, @unchecked Sendable {
try await setNameNameClosure?(name)
}
//MARK: - setOwnMemberDisplayName
open var setOwnMemberDisplayNameDisplayNameThrowableError: Error?
open var setOwnMemberDisplayNameDisplayNameUnderlyingCallsCount = 0
open var setOwnMemberDisplayNameDisplayNameCallsCount: Int {
get {
if Thread.isMainThread {
return setOwnMemberDisplayNameDisplayNameUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = setOwnMemberDisplayNameDisplayNameUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setOwnMemberDisplayNameDisplayNameUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
setOwnMemberDisplayNameDisplayNameUnderlyingCallsCount = newValue
}
}
}
}
open var setOwnMemberDisplayNameDisplayNameCalled: Bool {
return setOwnMemberDisplayNameDisplayNameCallsCount > 0
}
open var setOwnMemberDisplayNameDisplayNameReceivedDisplayName: String?
open var setOwnMemberDisplayNameDisplayNameReceivedInvocations: [String?] = []
open var setOwnMemberDisplayNameDisplayNameClosure: ((String?) async throws -> Void)?
open override func setOwnMemberDisplayName(displayName: String?) async throws {
if let error = setOwnMemberDisplayNameDisplayNameThrowableError {
throw error
}
setOwnMemberDisplayNameDisplayNameCallsCount += 1
setOwnMemberDisplayNameDisplayNameReceivedDisplayName = displayName
DispatchQueue.main.async {
self.setOwnMemberDisplayNameDisplayNameReceivedInvocations.append(displayName)
}
try await setOwnMemberDisplayNameDisplayNameClosure?(displayName)
}
//MARK: - setThreadSubscription
open var setThreadSubscriptionThreadRootEventIdSubscribedThrowableError: Error?
@@ -23601,6 +23647,71 @@ open class SpaceServiceSDKMock: MatrixRustSDK.SpaceService, @unchecked Sendable
try await removeChildFromSpaceChildIdSpaceIdClosure?(childId, spaceId)
}
//MARK: - spaceFilters
open var spaceFiltersUnderlyingCallsCount = 0
open var spaceFiltersCallsCount: Int {
get {
if Thread.isMainThread {
return spaceFiltersUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = spaceFiltersUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
spaceFiltersUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
spaceFiltersUnderlyingCallsCount = newValue
}
}
}
}
open var spaceFiltersCalled: Bool {
return spaceFiltersCallsCount > 0
}
open var spaceFiltersUnderlyingReturnValue: [SpaceFilter]!
open var spaceFiltersReturnValue: [SpaceFilter]! {
get {
if Thread.isMainThread {
return spaceFiltersUnderlyingReturnValue
} else {
var returnValue: [SpaceFilter]? = nil
DispatchQueue.main.sync {
returnValue = spaceFiltersUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
spaceFiltersUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
spaceFiltersUnderlyingReturnValue = newValue
}
}
}
}
open var spaceFiltersClosure: (() async -> [SpaceFilter])?
open override func spaceFilters() async -> [SpaceFilter] {
spaceFiltersCallsCount += 1
if let spaceFiltersClosure = spaceFiltersClosure {
return await spaceFiltersClosure()
} else {
return spaceFiltersReturnValue
}
}
//MARK: - spaceRoomList
open var spaceRoomListSpaceIdThrowableError: Error?
@@ -23676,6 +23787,77 @@ open class SpaceServiceSDKMock: MatrixRustSDK.SpaceService, @unchecked Sendable
}
}
//MARK: - subscribeToSpaceFilters
open var subscribeToSpaceFiltersListenerUnderlyingCallsCount = 0
open var subscribeToSpaceFiltersListenerCallsCount: Int {
get {
if Thread.isMainThread {
return subscribeToSpaceFiltersListenerUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = subscribeToSpaceFiltersListenerUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToSpaceFiltersListenerUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
subscribeToSpaceFiltersListenerUnderlyingCallsCount = newValue
}
}
}
}
open var subscribeToSpaceFiltersListenerCalled: Bool {
return subscribeToSpaceFiltersListenerCallsCount > 0
}
open var subscribeToSpaceFiltersListenerReceivedListener: SpaceServiceSpaceFiltersListener?
open var subscribeToSpaceFiltersListenerReceivedInvocations: [SpaceServiceSpaceFiltersListener] = []
open var subscribeToSpaceFiltersListenerUnderlyingReturnValue: TaskHandle!
open var subscribeToSpaceFiltersListenerReturnValue: TaskHandle! {
get {
if Thread.isMainThread {
return subscribeToSpaceFiltersListenerUnderlyingReturnValue
} else {
var returnValue: TaskHandle? = nil
DispatchQueue.main.sync {
returnValue = subscribeToSpaceFiltersListenerUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToSpaceFiltersListenerUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
subscribeToSpaceFiltersListenerUnderlyingReturnValue = newValue
}
}
}
}
open var subscribeToSpaceFiltersListenerClosure: ((SpaceServiceSpaceFiltersListener) async -> TaskHandle)?
open override func subscribeToSpaceFilters(listener: SpaceServiceSpaceFiltersListener) async -> TaskHandle {
subscribeToSpaceFiltersListenerCallsCount += 1
subscribeToSpaceFiltersListenerReceivedListener = listener
DispatchQueue.main.async {
self.subscribeToSpaceFiltersListenerReceivedInvocations.append(listener)
}
if let subscribeToSpaceFiltersListenerClosure = subscribeToSpaceFiltersListenerClosure {
return await subscribeToSpaceFiltersListenerClosure(listener)
} else {
return subscribeToSpaceFiltersListenerReturnValue
}
}
//MARK: - subscribeToTopLevelJoinedSpaces
open var subscribeToTopLevelJoinedSpacesListenerUnderlyingCallsCount = 0
@@ -82,6 +82,25 @@ final class RoomSummaryProviderTests: XCTestCase {
.all(filters: [.all(filters: [.category(expect: .group), .joined])] + baseFilters + [.nonLowPriority]))
}
func testRoomIdentifierFilters() async {
setupProvider()
await Task.yield()
// Then it should have the default Rust filters enabled.
XCTAssertEqual(dynamicEntriesController.setFilterKindCallsCount, 1)
XCTAssertEqual(dynamicEntriesController.setFilterKindReceivedInvocations.last,
.all(filters: baseFilters))
// When setting one our user filters.
roomSummaryProvider.setFilter(.rooms(roomsIDs: ["SomeRoom"], filters: [.favourites]))
await Task.yield()
// Then that filter should be added to the default Rust filters.
XCTAssertEqual(dynamicEntriesController.setFilterKindCallsCount, 2)
XCTAssertEqual(dynamicEntriesController.setFilterKindReceivedInvocations.last,
.all(filters: [.all(filters: [.favourite, .joined])] + baseFilters + [.identifiers(identifiers: ["SomeRoom"])]))
}
// MARK: - Helpers
private func setupProvider(isLowPriorityFilterEnabled: Bool = false) {