diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 81851cf45..202e5d45a 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -243,6 +243,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "KnockRequestsListScreen_Previews") } + func testLeaveSpaceView() async throws { + try await performAccessibilityAudit(named: "LeaveSpaceView_Previews") + } + func testLegalInformationScreen() async throws { try await performAccessibilityAudit(named: "LegalInformationScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 6632f1af0..708d93632 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1012,6 +1012,7 @@ B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; B6064D82FCDCB829601C1F59 /* SecureBackupLogoutConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */; }; B6B62437B92B6CA4083AA899 /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; }; + B6CA5D18D702D0919BEF0263 /* LeaveSpaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7813824C547ED121F6F8E0F /* LeaveSpaceView.swift */; }; B6DA66EFC13A90846B625836 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 91DE43B8815918E590912DDA /* InfoPlist.strings */; }; B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */; }; B6EC2148FA5443C9289BEEBA /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; @@ -1206,6 +1207,7 @@ DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */; }; DC77E9DB2CFBE84A2BDF20C5 /* RoomRolesAndPermissionsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */; }; DCFE7CB3B9A104330BBB96AD /* AnalyticsPromptScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B67DF223EEB8DCAF178A1D4 /* AnalyticsPromptScreenCoordinator.swift */; }; + DD21CE51DF9BD04FC8155972 /* LeaveSpaceHandleSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */; }; DDB47D29C6865669288BF87C /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */; }; DDE7B4771452300C103B1EB8 /* RoomDirectoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6210134203BE1F2DD5C679 /* RoomDirectoryCell.swift */; }; DDFBDEE1DC32BDD5488F898C /* ClientProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */; }; @@ -1308,6 +1310,7 @@ EF79B9EFD094C17FBB4942C2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; }; EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */; }; EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */; }; + EFF735EC040BEB669AFBAB50 /* LeaveSpaceHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */; }; F0570F1ECD70C4C851FB2052 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E7304F5ECB4CB11CB10E60 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift */; }; F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; @@ -1961,6 +1964,7 @@ 57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionSettingsTests.swift; sourceTree = ""; }; 57EAAF82432B0B53881CF826 /* AudioRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineItem.swift; sourceTree = ""; }; 57F95CADD0A5DBD76B990FCB /* ServiceLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLocator.swift; sourceTree = ""; }; + 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceHandleSDKMock.swift; sourceTree = ""; }; 584A61D9C459FAFEF038A7C0 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; 5875F7C0A2398E9F134B1284 /* EncryptionResetScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenViewModel.swift; sourceTree = ""; }; 58C2527813FDAE23E72A9063 /* AnalyticsSettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelTests.swift; sourceTree = ""; }; @@ -2096,6 +2100,7 @@ 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signposter.swift; sourceTree = ""; }; 753B4C6C0EDDCBF0708DC384 /* TimelineItemSendInfoLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSendInfoLabel.swift; sourceTree = ""; }; 75821CD31A4BD02B99C327A4 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; + 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceHandleProxy.swift; sourceTree = ""; }; 76310030C831D4610A705603 /* URLComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsTests.swift; sourceTree = ""; }; 76A46ABD27628CB5FC402541 /* Backports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backports.swift; sourceTree = ""; }; 7720ACAC6155AB7F9C70B546 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nb; path = nb.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -2609,6 +2614,7 @@ D751AA05AD2182BFC4608DE6 /* ur */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ur; path = ur.lproj/Localizable.stringsdict; sourceTree = ""; }; D7673F2B0B038FAB2A8D16AD /* ElementTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementTextFieldStyle.swift; sourceTree = ""; }; D77F75B3E9F99864048A422A /* DeactivateAccountScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModelTests.swift; sourceTree = ""; }; + D7813824C547ED121F6F8E0F /* LeaveSpaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceView.swift; sourceTree = ""; }; D78C13EF5035879B6131030F /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; D79BB714D28C9F588DD69353 /* SecureBackupScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenViewModelProtocol.swift; sourceTree = ""; }; D7B18089ED50324583BB2FB7 /* EditRoomAddressScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -5503,6 +5509,7 @@ AAAB1791344F28CDC62E764D /* Spaces */ = { isa = PBXGroup; children = ( + 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */, 4B2B564CA6570E1487A7C7CC /* SpaceRoomListProxy.swift */, EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */, 355C8C46DA9C0B45F1B7FC4F /* SpaceRoomProxy.swift */, @@ -6218,6 +6225,7 @@ 8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */, 0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */, 5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */, + 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */, ); path = SDK; sourceTree = ""; @@ -6425,6 +6433,7 @@ FA1D480A302295CFC3582543 /* View */ = { isa = PBXGroup; children = ( + D7813824C547ED121F6F8E0F /* LeaveSpaceView.swift */, 646B50583A2CE6DA67F7739A /* SpaceScreen.swift */, ); path = View; @@ -7828,6 +7837,9 @@ E468CC731C3F4D678499E52F /* LAContextMock.swift in Sources */, D5681C80D8281560AACE0035 /* Label.swift in Sources */, EEAE954289DE813A61656AE0 /* LayoutDirection.swift in Sources */, + EFF735EC040BEB669AFBAB50 /* LeaveSpaceHandleProxy.swift in Sources */, + DD21CE51DF9BD04FC8155972 /* LeaveSpaceHandleSDKMock.swift in Sources */, + B6CA5D18D702D0919BEF0263 /* LeaveSpaceView.swift in Sources */, 42B084FDE621FBEE433AF444 /* LegalInformationScreen.swift in Sources */, 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */, F40B097470D3110DFDB1FAAA /* LegalInformationScreenModels.swift in Sources */, @@ -9364,7 +9376,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 25.09.26; + version = 25.10.02; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7af40e8ee..1e1549510 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "c79dc9374e580b74084535ebe09925764939058b", - "version" : "25.9.26" + "revision" : "bd2e8c25c5e179b272a0718ac6beca9dcb32f0a3", + "version" : "25.10.2" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 088ea2319..6e1b65056 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -102,6 +102,7 @@ "action_manage_account" = "Manage account"; "action_manage_devices" = "Manage devices"; "action_message" = "Message"; +"action_minimize" = "Minimise"; "action_next" = "Next"; "action_no" = "No"; "action_not_now" = "Not now"; @@ -149,11 +150,11 @@ "action_tap_for_options" = "Tap for options"; "action_try_again" = "Try again"; "action_unpin" = "Unpin"; +"action_view" = "View"; "action_view_in_timeline" = "View in timeline"; "action_view_source" = "View source"; "action_yes" = "Yes"; "action_yes_try_again" = "Yes, try again"; -"action.view" = "View"; "banner_migrate_to_native_sliding_sync_action" = "Log Out & Upgrade"; "banner_migrate_to_native_sliding_sync_app_force_logout_title" = "%1$@ no longer supports the old protocol. Please log out and log back in to continue using the app."; "banner_migrate_to_native_sliding_sync_description" = "Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."; @@ -219,6 +220,7 @@ "common_in_reply_to" = "In reply to %1$@"; "common_invite_unknown_profile" = "This Matrix ID can't be found, so the invite might not be received."; "common_leaving_room" = "Leaving room"; +"common_leaving_space" = "Leaving space"; "common_light" = "Light"; "common_line_copied_to_clipboard" = "Line copied to clipboard"; "common_link_copied_to_clipboard" = "Link copied to clipboard"; @@ -565,8 +567,9 @@ "screen_knock_requests_list_empty_state_description" = "When somebody will ask to join the room, you’ll be able to see their request here."; "screen_knock_requests_list_empty_state_title" = "No pending request to join"; "screen_knock_requests_list_initial_loading_title" = "Loading requests to join…"; -"screen_leave_space_last_admin_info" = "(Admin)"; +"screen_leave_space_last_admin_info" = "%1$@ (Admin)"; "screen_leave_space_subtitle" = "Select the rooms you’d like to leave which you're not the only administrator for:"; +"screen_leave_space_subtitle_only_last_admin" = "You will not be removed from the following room(s) because you're the only administrator:"; "screen_leave_space_title" = "Leave %1$@?"; "screen_media_details_file_format" = "File format"; "screen_media_details_filename" = "File name"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index c22a3d89f..4b2454572 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -290,22 +290,6 @@ Leave %1$d rooms and space - screen_leave_space_subtitle_only_last_admin - - NSStringLocalizedFormatKey - %#@COUNT@ - COUNT - - NSStringFormatSpecTypeKey - NSStringPluralRuleType - NSStringFormatValueTypeKey - d - one - You will not be removed from the following room because you're the only administrator: - other - You will not be removed from the following rooms because you're the only administrator: - - screen_pinned_timeline_screen_title NSStringLocalizedFormatKey diff --git a/ElementX/Sources/FlowCoordinators/SpaceExplorerFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SpaceExplorerFlowCoordinator.swift index a4b06a20c..250950cb5 100644 --- a/ElementX/Sources/FlowCoordinators/SpaceExplorerFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SpaceExplorerFlowCoordinator.swift @@ -104,6 +104,7 @@ class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol { return .spaceList(selectedSpaceID: nil) } handler: { [weak self] _ in guard let self else { return } + navigationSplitCoordinator.setDetailCoordinator(nil) // If we forget to do this, the tab bar remains hidden. selectedSpaceSubject.send(nil) spaceFlowCoordinator = nil } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 8066a5995..f07318bab 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -242,6 +242,8 @@ internal enum L10n { internal static var actionManageDevices: String { return L10n.tr("Localizable", "action_manage_devices") } /// Message internal static var actionMessage: String { return L10n.tr("Localizable", "action_message") } + /// Minimise + internal static var actionMinimize: String { return L10n.tr("Localizable", "action_minimize") } /// Next internal static var actionNext: String { return L10n.tr("Localizable", "action_next") } /// No @@ -336,6 +338,8 @@ internal enum L10n { internal static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") } /// Unpin internal static var actionUnpin: String { return L10n.tr("Localizable", "action_unpin") } + /// View + internal static var actionView: String { return L10n.tr("Localizable", "action_view") } /// View in timeline internal static var actionViewInTimeline: String { return L10n.tr("Localizable", "action_view_in_timeline") } /// View source @@ -484,6 +488,8 @@ internal enum L10n { internal static var commonInviteUnknownProfile: String { return L10n.tr("Localizable", "common_invite_unknown_profile") } /// Leaving room internal static var commonLeavingRoom: String { return L10n.tr("Localizable", "common_leaving_room") } + /// Leaving space + internal static var commonLeavingSpace: String { return L10n.tr("Localizable", "common_leaving_space") } /// Light internal static var commonLight: String { return L10n.tr("Localizable", "common_light") } /// Line copied to clipboard @@ -1808,18 +1814,18 @@ internal enum L10n { internal static var screenKnockRequestsListInitialLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_initial_loading_title") } /// Requests to join internal static var screenKnockRequestsListTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_title") } - /// (Admin) - internal static var screenLeaveSpaceLastAdminInfo: String { return L10n.tr("Localizable", "screen_leave_space_last_admin_info") } + /// %1$@ (Admin) + internal static func screenLeaveSpaceLastAdminInfo(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_leave_space_last_admin_info", String(describing: p1)) + } /// Plural format key: "%#@COUNT@" internal static func screenLeaveSpaceSubmit(_ p1: Int) -> String { return L10n.tr("Localizable", "screen_leave_space_submit", p1) } /// Select the rooms you’d like to leave which you're not the only administrator for: internal static var screenLeaveSpaceSubtitle: String { return L10n.tr("Localizable", "screen_leave_space_subtitle") } - /// Plural format key: "%#@COUNT@" - internal static func screenLeaveSpaceSubtitleOnlyLastAdmin(_ p1: Int) -> String { - return L10n.tr("Localizable", "screen_leave_space_subtitle_only_last_admin", p1) - } + /// You will not be removed from the following room(s) because you're the only administrator: + internal static var screenLeaveSpaceSubtitleOnlyLastAdmin: String { return L10n.tr("Localizable", "screen_leave_space_subtitle_only_last_admin") } /// Leave %1$@? internal static func screenLeaveSpaceTitle(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_leave_space_title", String(describing: p1)) @@ -3340,11 +3346,6 @@ internal enum L10n { internal static var yourAvatar: String { return L10n.tr("Localizable", "a11y.your_avatar") } } - internal enum Action { - /// View - internal static var view: String { return L10n.tr("Localizable", "action.view") } - } - internal enum Common { /// Add an account internal static var addAccount: String { return L10n.tr("Localizable", "common.add_account") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 6d4d01b0b..a7e79c320 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -16111,6 +16111,76 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable { return spaceRoomListSpaceIDParentReturnValue } } + //MARK: - leaveSpace + + var leaveSpaceSpaceIDUnderlyingCallsCount = 0 + var leaveSpaceSpaceIDCallsCount: Int { + get { + if Thread.isMainThread { + return leaveSpaceSpaceIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = leaveSpaceSpaceIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + leaveSpaceSpaceIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + leaveSpaceSpaceIDUnderlyingCallsCount = newValue + } + } + } + } + var leaveSpaceSpaceIDCalled: Bool { + return leaveSpaceSpaceIDCallsCount > 0 + } + var leaveSpaceSpaceIDReceivedSpaceID: String? + var leaveSpaceSpaceIDReceivedInvocations: [String] = [] + + var leaveSpaceSpaceIDUnderlyingReturnValue: Result! + var leaveSpaceSpaceIDReturnValue: Result! { + get { + if Thread.isMainThread { + return leaveSpaceSpaceIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = leaveSpaceSpaceIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + leaveSpaceSpaceIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + leaveSpaceSpaceIDUnderlyingReturnValue = newValue + } + } + } + } + var leaveSpaceSpaceIDClosure: ((String) async -> Result)? + + func leaveSpace(spaceID: String) async -> Result { + leaveSpaceSpaceIDCallsCount += 1 + leaveSpaceSpaceIDReceivedSpaceID = spaceID + DispatchQueue.main.async { + self.leaveSpaceSpaceIDReceivedInvocations.append(spaceID) + } + if let leaveSpaceSpaceIDClosure = leaveSpaceSpaceIDClosure { + return await leaveSpaceSpaceIDClosure(spaceID) + } else { + return leaveSpaceSpaceIDReturnValue + } + } } class StaticRoomSummaryProviderMock: StaticRoomSummaryProviderProtocol, @unchecked Sendable { var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> { diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index 0a52ec542..0cfef4c15 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -10135,6 +10135,128 @@ open class LazyTimelineItemProviderSDKMock: MatrixRustSDK.LazyTimelineItemProvid } } } +open class LeaveSpaceHandleSDKMock: MatrixRustSDK.LeaveSpaceHandle, @unchecked Sendable { + init() { + super.init(noPointer: .init()) + } + + public required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + fatalError("init(unsafeFromRawPointer:) has not been implemented") + } + + fileprivate var pointer: UnsafeMutableRawPointer! + + //MARK: - leave + + open var leaveRoomIdsThrowableError: Error? + var leaveRoomIdsUnderlyingCallsCount = 0 + open var leaveRoomIdsCallsCount: Int { + get { + if Thread.isMainThread { + return leaveRoomIdsUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = leaveRoomIdsUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + leaveRoomIdsUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + leaveRoomIdsUnderlyingCallsCount = newValue + } + } + } + } + open var leaveRoomIdsCalled: Bool { + return leaveRoomIdsCallsCount > 0 + } + open var leaveRoomIdsReceivedRoomIds: [String]? + open var leaveRoomIdsReceivedInvocations: [[String]] = [] + open var leaveRoomIdsClosure: (([String]) async throws -> Void)? + + open override func leave(roomIds: [String]) async throws { + if let error = leaveRoomIdsThrowableError { + throw error + } + leaveRoomIdsCallsCount += 1 + leaveRoomIdsReceivedRoomIds = roomIds + DispatchQueue.main.async { + self.leaveRoomIdsReceivedInvocations.append(roomIds) + } + try await leaveRoomIdsClosure?(roomIds) + } + + //MARK: - rooms + + var roomsUnderlyingCallsCount = 0 + open var roomsCallsCount: Int { + get { + if Thread.isMainThread { + return roomsUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = roomsUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomsUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + roomsUnderlyingCallsCount = newValue + } + } + } + } + open var roomsCalled: Bool { + return roomsCallsCount > 0 + } + + var roomsUnderlyingReturnValue: [LeaveSpaceRoom]! + open var roomsReturnValue: [LeaveSpaceRoom]! { + get { + if Thread.isMainThread { + return roomsUnderlyingReturnValue + } else { + var returnValue: [LeaveSpaceRoom]? = nil + DispatchQueue.main.sync { + returnValue = roomsUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomsUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + roomsUnderlyingReturnValue = newValue + } + } + } + } + open var roomsClosure: (() -> [LeaveSpaceRoom])? + + open override func rooms() -> [LeaveSpaceRoom] { + roomsCallsCount += 1 + if let roomsClosure = roomsClosure { + return roomsClosure() + } else { + return roomsReturnValue + } + } +} open class MediaFileHandleSDKMock: MatrixRustSDK.MediaFileHandle, @unchecked Sendable { init() { super.init(noPointer: .init()) @@ -22227,6 +22349,81 @@ open class SpaceServiceSDKMock: MatrixRustSDK.SpaceService, @unchecked Sendable } } + //MARK: - leaveSpace + + open var leaveSpaceSpaceIdThrowableError: Error? + var leaveSpaceSpaceIdUnderlyingCallsCount = 0 + open var leaveSpaceSpaceIdCallsCount: Int { + get { + if Thread.isMainThread { + return leaveSpaceSpaceIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = leaveSpaceSpaceIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + leaveSpaceSpaceIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + leaveSpaceSpaceIdUnderlyingCallsCount = newValue + } + } + } + } + open var leaveSpaceSpaceIdCalled: Bool { + return leaveSpaceSpaceIdCallsCount > 0 + } + open var leaveSpaceSpaceIdReceivedSpaceId: String? + open var leaveSpaceSpaceIdReceivedInvocations: [String] = [] + + var leaveSpaceSpaceIdUnderlyingReturnValue: LeaveSpaceHandle! + open var leaveSpaceSpaceIdReturnValue: LeaveSpaceHandle! { + get { + if Thread.isMainThread { + return leaveSpaceSpaceIdUnderlyingReturnValue + } else { + var returnValue: LeaveSpaceHandle? = nil + DispatchQueue.main.sync { + returnValue = leaveSpaceSpaceIdUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + leaveSpaceSpaceIdUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + leaveSpaceSpaceIdUnderlyingReturnValue = newValue + } + } + } + } + open var leaveSpaceSpaceIdClosure: ((String) async throws -> LeaveSpaceHandle)? + + open override func leaveSpace(spaceId: String) async throws -> LeaveSpaceHandle { + if let error = leaveSpaceSpaceIdThrowableError { + throw error + } + leaveSpaceSpaceIdCallsCount += 1 + leaveSpaceSpaceIdReceivedSpaceId = spaceId + DispatchQueue.main.async { + self.leaveSpaceSpaceIdReceivedInvocations.append(spaceId) + } + if let leaveSpaceSpaceIdClosure = leaveSpaceSpaceIdClosure { + return try await leaveSpaceSpaceIdClosure(spaceId) + } else { + return leaveSpaceSpaceIdReturnValue + } + } + //MARK: - spaceRoomList open var spaceRoomListSpaceIdThrowableError: Error? diff --git a/ElementX/Sources/Mocks/SDK/LeaveSpaceHandleSDKMock.swift b/ElementX/Sources/Mocks/SDK/LeaveSpaceHandleSDKMock.swift new file mode 100644 index 000000000..07f92b710 --- /dev/null +++ b/ElementX/Sources/Mocks/SDK/LeaveSpaceHandleSDKMock.swift @@ -0,0 +1,142 @@ +// +// Copyright 2025 New Vector 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 +import MatrixRustSDK + +extension LeaveSpaceHandleSDKMock { + struct Configuration { + var rooms: [LeaveSpaceRoom] = .mockRooms + } + + convenience init(_ configuration: Configuration) { + self.init() + + roomsClosure = { configuration.rooms } + } +} + +extension [LeaveSpaceRoom] { + static var mockAdminRooms: [LeaveSpaceRoom] { + mockRooms.filter(\.isLastAdmin) + } + + static var mockRooms: [LeaveSpaceRoom] { + [ + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "1", + name: "Lighting", + avatarURL: .mockMXCAvatar, + isSpace: false, + memberCount: 10, + joinRule: .public), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "2", + name: "Sound", + isSpace: false, + memberCount: 20, + joinRule: .private), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "3", + name: "Set & Costume", + isSpace: false, + memberCount: 25, + joinRule: .restricted(rules: [])), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "4", + name: "The Theatre", + isSpace: true, + memberCount: 100, + joinRule: .private, + childrenCount: 20), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "5", + name: "Bookings", + isSpace: false, + memberCount: 200, + joinRule: .private, + childrenCount: 0), + isLastAdmin: true), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "6", + name: "Events", + isSpace: false, + memberCount: 65, + joinRule: .restricted(rules: []), + childrenCount: 0), + isLastAdmin: true), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "7", + name: "Mario Kart", + isSpace: false, + memberCount: 123, + joinRule: .public), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "8", + name: "Tetris", + isSpace: false, + memberCount: 95, + joinRule: .public), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "9", + name: "Minecraft", + isSpace: false, + memberCount: 39, + joinRule: .public), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "10", + name: "Lemmings", + isSpace: false, + memberCount: 67, + joinRule: .public), + isLastAdmin: true), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "11", + name: "Rayman", + isSpace: false, + memberCount: 23, + joinRule: .public), + isLastAdmin: false), + LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "12", + name: "Gaming", + avatarURL: .mockMXCAvatar, + isSpace: true, + memberCount: 835, + joinRule: .public, + childrenCount: 15), + isLastAdmin: true) + ] + } +} + +private extension SpaceRoom { + init(id: String, + canonicalAlias: String? = nil, + name: String, + topic: String? = nil, + avatarURL: URL? = nil, + isSpace: Bool, + memberCount: UInt64, + joinRule: JoinRule? = .public, + isDirect: Bool? = false, + childrenCount: UInt64 = 0, + membership: Membership? = .joined, + heroes: [RoomHero]? = [], + via: [String] = []) { + self.init(roomId: id, + canonicalAlias: canonicalAlias, + name: name, + topic: topic, + avatarUrl: avatarURL?.absoluteString, + roomType: isSpace ? .space : .room, + numJoinedMembers: memberCount, + joinRule: joinRule, + worldReadable: true, + guestCanJoin: false, + isDirect: isDirect, + childrenCount: childrenCount, + state: membership, + heroes: heroes, + via: via) + } +} diff --git a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift index 48c270675..ce2b6eaa2 100644 --- a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift +++ b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift @@ -13,6 +13,7 @@ extension SpaceServiceProxyMock { struct Configuration { var joinedSpaces: [SpaceRoomProxyProtocol] = [] var spaceRoomLists: [String: SpaceRoomListProxyMock] = [:] + var leaveSpaceRooms: [LeaveSpaceRoom] = [] } convenience init(_ configuration: Configuration) { @@ -26,6 +27,10 @@ extension SpaceServiceProxyMock { .failure(.sdkError(ClientProxyMockError.generic)) } } + leaveSpaceSpaceIDClosure = { spaceID in + .success(LeaveSpaceHandleProxy(spaceID: spaceID, + leaveHandle: LeaveSpaceHandleSDKMock(.init(rooms: configuration.leaveSpaceRooms)))) + } } } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 924480c35..7275bca02 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -134,6 +134,7 @@ enum RoomAvatarSizeOnScreen { case chats case spaces case timeline + case leaveSpace case messageForwarding case globalSearch case roomSelection @@ -148,7 +149,7 @@ enum RoomAvatarSizeOnScreen { switch self { case .chats, .spaces: return 52 - case .timeline: + case .timeline, .leaveSpace: return 32 case .notificationSettings: return 30 diff --git a/ElementX/Sources/Other/SwiftUI/Backports.swift b/ElementX/Sources/Other/SwiftUI/Backports.swift index b7d498430..dd47f69f7 100644 --- a/ElementX/Sources/Other/SwiftUI/Backports.swift +++ b/ElementX/Sources/Other/SwiftUI/Backports.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Compound import SwiftUI extension View { @@ -55,6 +56,18 @@ extension View { } } + @ViewBuilder + func backportSafeAreaBar(edge: VerticalEdge, + alignment: HorizontalAlignment = .center, + spacing: CGFloat? = nil, + content: () -> some View) -> some View { + if #available(iOS 26.0, *) { + safeAreaBar(edge: edge, alignment: alignment, spacing: spacing, content: content) + } else { + safeAreaInset(edge: edge, alignment: alignment, spacing: spacing) { content().background(Color.compound.bgCanvasDefault.ignoresSafeArea()) } + } + } + @ViewBuilder func backportScrollEdgeEffectHidden() -> some View { if #available(iOS 26, *) { scrollEdgeEffectHidden() diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 53ff42a4b..bc40109a3 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -68,6 +68,7 @@ enum TestablePreviewsDictionary { "KnockRequestsBannerView_Previews" : KnockRequestsBannerView_Previews.self, "KnockRequestsListEmptyStateView_Previews" : KnockRequestsListEmptyStateView_Previews.self, "KnockRequestsListScreen_Previews" : KnockRequestsListScreen_Previews.self, + "LeaveSpaceView_Previews" : LeaveSpaceView_Previews.self, "LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self, "LoadableImage_Previews" : LoadableImage_Previews.self, "LocationMarkerView_Previews" : LocationMarkerView_Previews.self, diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift index 0248b9b03..080acb937 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift @@ -29,9 +29,14 @@ struct SpaceScreenViewState: BindableState { var spaceName: String { space.name ?? L10n.commonSpace } } -struct SpaceScreenViewStateBindings { } +struct SpaceScreenViewStateBindings { + var leaveHandle: LeaveSpaceHandleProxy? +} enum SpaceScreenViewAction { case spaceAction(SpaceRoomCell.Action) case leaveSpace + case deselectAllLeaveRoomDetails + case toggleLeaveSpaceRoomDetails(id: String) + case confirmLeaveSpace } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift index e07b3c91f..8eb91aa96 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift @@ -89,14 +89,21 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc case .spaceAction(.join(let spaceRoomProxy)): Task { await join(spaceRoomProxy) } case .leaveSpace: - #if DEBUG - Task { // Temporary implementation to make joining a space easier to test. - if case let .joined(roomProxy) = await clientProxy.roomForIdentifier(spaceRoomListProxy.spaceRoomProxy.id), - case .success = await roomProxy.leaveRoom() { - actionsSubject.send(.leftSpace) - } + Task { await showLeaveSpaceConfirmation() } + case .deselectAllLeaveRoomDetails: + guard let leaveHandle = state.bindings.leaveHandle else { fatalError("The leave handle should be available.") } + for room in leaveHandle.rooms { + room.isSelected = false } - #endif + case .toggleLeaveSpaceRoomDetails(let spaceRoomID): + guard let room = state.bindings.leaveHandle?.rooms.first(where: { $0.spaceRoomProxy.id == spaceRoomID }) else { + fatalError("The space room to toggle is not in the list of rooms to leave.") + } + withTransaction(\.disablesAnimations, true) { // The button is adding an unwanted animation. + room.isSelected.toggle() + } + case .confirmLeaveSpace: + Task { await confirmLeaveSpace() } } } @@ -136,10 +143,45 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc } } + private func showLeaveSpaceConfirmation() async { + guard case let .success(leaveHandle) = await spaceServiceProxy.leaveSpace(spaceID: spaceRoomListProxy.spaceRoomProxy.id) else { + showFailureIndicator() + return + } + + state.bindings.leaveHandle = leaveHandle + } + + private func confirmLeaveSpace() async { + guard let leaveHandle = state.bindings.leaveHandle else { fatalError("Leaving without a handle is impossible.") } + + showLeavingIndicator() + defer { hideLeavingIndicator() } + + switch await leaveHandle.leave() { + case .success: + state.bindings.leaveHandle = nil + actionsSubject.send(.leftSpace) + case .failure: + showFailureIndicator() + } + } + // MARK: - Indicators + private static var leavingIndicatorID: String { "\(Self.self)-Leaving" } private static var failureIndicatorID: String { "\(Self.self)-Failure" } + private func showLeavingIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: Self.leavingIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonLeavingSpace)) + } + + private func hideLeavingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.leavingIndicatorID) + } + private func showFailureIndicator() { userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID, type: .toast, diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/LeaveSpaceView.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/LeaveSpaceView.swift new file mode 100644 index 000000000..d60430125 --- /dev/null +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/LeaveSpaceView.swift @@ -0,0 +1,233 @@ +// +// Copyright 2025 New Vector 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 LeaveSpaceView: View { + let context: SpaceScreenViewModel.Context + + @State private var scrollViewHeight: CGFloat = .zero + @State private var buttonsHeight: CGFloat = .zero + private let topPadding = 19.0 + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + rooms + } + .readHeight($scrollViewHeight) + } + .backportSafeAreaBar(edge: .bottom, spacing: 0) { + buttons + .readHeight($buttonsHeight) + } + .scrollBounceBehavior(.basedOnSize) + .padding(.top, topPadding) // For the drag indicator + .presentationDetents([.height(scrollViewHeight + buttonsHeight + topPadding)]) + .presentationDragIndicator(.visible) + .presentationBackground(.compound.bgCanvasDefault) + } + + var header: some View { + VStack(spacing: 16) { + BigIcon(icon: \.errorSolid, style: .alertSolid) + + VStack(spacing: 8) { + Text(L10n.screenLeaveSpaceTitle(context.viewState.spaceName)) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + + switch context.leaveHandle?.mode { + case .manyRooms: + Text(L10n.screenLeaveSpaceSubtitle) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + case .onlyAdminRooms: + Text(L10n.screenLeaveSpaceSubtitleOnlyLastAdmin) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + case .noRooms, nil: + EmptyView() + } + } + } + .padding(24) + } + + @ViewBuilder + var rooms: some View { + if let leaveRooms = context.leaveHandle?.rooms, !leaveRooms.isEmpty { + LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { + Section { + ForEach(leaveRooms, id: \.spaceRoomProxy.id) { room in + LeaveSpaceRoomDetailsCell(room: room, mediaProvider: context.mediaProvider) { + context.send(viewAction: .toggleLeaveSpaceRoomDetails(id: room.spaceRoomProxy.id)) + } + .disabled(room.isLastAdmin) + } + } header: { + Button(L10n.commonDeselectAll) { + context.send(viewAction: .deselectAllLeaveRoomDetails) + } + .buttonStyle(.compound(.textLink, size: .small)) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + } + } + } + } + + var buttons: some View { + VStack(spacing: 16) { + Button(role: .destructive) { + context.send(viewAction: .confirmLeaveSpace) + } label: { + Label(context.leaveHandle?.confirmationTitle ?? L10n.actionLeaveSpace, icon: \.leave) + } + .buttonStyle(.compound(.primary)) + + Button(L10n.actionCancel) { + context.leaveHandle = nil + } + .buttonStyle(.compound(.tertiary)) + } + .padding(.horizontal, 16) + .padding(.top, 16) + } +} + +struct LeaveSpaceRoomDetailsCell: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + let room: LeaveSpaceRoomDetails + let mediaProvider: MediaProviderProtocol? + + let action: () -> Void + + private var subtitle: String? { + guard !room.spaceRoomProxy.isSpace else { return nil } + let memberCount = L10n.commonMemberCount(room.spaceRoomProxy.joinedMembersCount) + return room.isLastAdmin ? L10n.screenLeaveSpaceLastAdminInfo(memberCount) : memberCount + } + + var visibilityIcon: KeyPath? { + switch room.spaceRoomProxy.visibility { + case .public: \.public + case .private: \.lockSolid + case .restricted: nil + case .none: \.lockSolid + } + } + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + if dynamicTypeSize < .accessibility3 { + RoomAvatarImage(avatar: room.spaceRoomProxy.avatar, + avatarSize: .room(on: .leaveSpace), + mediaProvider: mediaProvider) + } + + VStack(alignment: .leading, spacing: 0) { + Text(room.spaceRoomProxy.computedName) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + .lineLimit(1) + .padding(.vertical, 1) + .padding(.vertical, subtitle == nil ? 10 : 0) + + subtitleLabel + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + + ListRowAccessory.multiSelection(room.isSelected) + } + .padding(.horizontal, 16) + } + .buttonStyle(SpaceRoomCellButtonStyle(isSelected: false)) + } + + @ViewBuilder + private var subtitleLabel: some View { + if let subtitle { + Label { + Text(subtitle) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .lineLimit(1) + .padding(.vertical, 1) + } icon: { + if let visibilityIcon { + CompoundIcon(visibilityIcon, + size: .xSmall, + relativeTo: .compound.bodyMD) + .foregroundStyle(.compound.iconTertiary) + } + } + .labelStyle(.custom(spacing: 4)) + } + } +} + +// MARK: - Previews + +import MatrixRustSDK + +struct LeaveSpaceView_Previews: PreviewProvider, TestablePreview { + static let viewModel = makeViewModel(mode: .manyRooms) + static let onlyAdminRoomsViewModel = makeViewModel(mode: .onlyAdminRooms) + static let noRoomsViewModel = makeViewModel(mode: .noRooms) + + static var previews: some View { + LeaveSpaceView(context: viewModel.context) + .previewDisplayName("Many Rooms") + .snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream()) + LeaveSpaceView(context: onlyAdminRoomsViewModel.context) + .previewDisplayName("Only Admin Rooms") + .snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream()) + LeaveSpaceView(context: noRoomsViewModel.context) + .previewDisplayName("No Rooms") + .snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream()) + } + + static func makeViewModel(mode: LeaveSpaceHandleProxy.Mode) -> SpaceScreenViewModel { + let spaceRoomProxy = SpaceRoomProxyMock(.init(id: "!eng-space:matrix.org", + name: "Engineering Team", + isSpace: true, + parent: SpaceRoomProxyMock(.init(name: "MegaGroup", isSpace: true)), + childrenCount: 30, + joinedMembersCount: 76, + heroes: [.mockDan, .mockBob, .mockCharlie, .mockVerbose], + topic: "Description of the space goes right here. Lorem ipsum dolor sit amet consectetur. Leo viverra morbi habitant in.", + joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")]))) + let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy, + initialSpaceRooms: .mockSpaceList)) + + let rooms: [LeaveSpaceRoom] = switch mode { + case .manyRooms: .mockRooms + case .onlyAdminRooms: .mockAdminRooms + case .noRooms: [] + } + let spaceServiceProxy = SpaceServiceProxyMock(.init(leaveSpaceRooms: rooms)) + + let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy, + spaceServiceProxy: spaceServiceProxy, + selectedSpaceRoomPublisher: .init(nil), + userSession: UserSessionMock(.init()), + userIndicatorController: UserIndicatorControllerMock()) + viewModel.context.send(viewAction: .leaveSpace) + return viewModel + } +} diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift index f8867fc4d..f27012884 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift @@ -23,6 +23,9 @@ struct SpaceScreen: View { .navigationTitle(context.viewState.spaceName) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } + .sheet(item: $context.leaveHandle) { _ in + LeaveSpaceView(context: context) + } } @ViewBuilder diff --git a/ElementX/Sources/Services/Spaces/LeaveSpaceHandleProxy.swift b/ElementX/Sources/Services/Spaces/LeaveSpaceHandleProxy.swift new file mode 100644 index 000000000..afa33b931 --- /dev/null +++ b/ElementX/Sources/Services/Spaces/LeaveSpaceHandleProxy.swift @@ -0,0 +1,76 @@ +// +// Copyright 2025 New Vector 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 +import MatrixRustSDK + +class LeaveSpaceHandleProxy: Identifiable { + let id: String + let leaveHandle: LeaveSpaceHandleProtocol + var rooms: [LeaveSpaceRoomDetails] + + enum Mode { case manyRooms, onlyAdminRooms, noRooms } + let mode: Mode + + init(spaceID: String, leaveHandle: LeaveSpaceHandleProtocol) { + id = spaceID + self.leaveHandle = leaveHandle + + rooms = leaveHandle.rooms() + .compactMap { room in + guard room.spaceRoom.state == .joined, // The SDK is going to do this but not yet. + room.spaceRoom.isDirect != true, + room.spaceRoom.roomId != spaceID else { + return nil + } + return .init(spaceRoomProxy: SpaceRoomProxy(spaceRoom: room.spaceRoom, parent: nil), + isLastAdmin: room.isLastAdmin, + isSelected: !room.isLastAdmin) + } + + mode = if rooms.isEmpty { + .noRooms + } else if rooms.count(where: { !$0.isLastAdmin }) == 0 { + .onlyAdminRooms + } else { + .manyRooms + } + } + + func leave() async -> Result { + let selectedRoomIDs = rooms.filter(\.isSelected).map(\.spaceRoomProxy.id) + + do { + return try await .success(leaveHandle.leave(roomIds: selectedRoomIDs + [id])) + } catch { + MXLog.error("Failed leaving space \(id): \(error)") + rooms = rooms.filter { leaveRoom in + leaveHandle.rooms().contains { $0.spaceRoom.roomId == leaveRoom.spaceRoomProxy.id } + } + return .failure(.sdkError(error)) + } + } + + var selectedCount: Int { rooms.count { $0.isSelected } } + + var confirmationTitle: String { + let selectedCount = selectedCount + return selectedCount > 0 ? L10n.screenLeaveSpaceSubmit(selectedCount) : L10n.actionLeaveSpace + } +} + +@Observable class LeaveSpaceRoomDetails { + let spaceRoomProxy: SpaceRoomProxyProtocol + let isLastAdmin: Bool + var isSelected: Bool + + init(spaceRoomProxy: SpaceRoomProxyProtocol, isLastAdmin: Bool, isSelected: Bool) { + self.spaceRoomProxy = spaceRoomProxy + self.isLastAdmin = isLastAdmin + self.isSelected = isSelected + } +} diff --git a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift index 8f36a0eef..24d446cd5 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift @@ -40,6 +40,15 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol { } } + func leaveSpace(spaceID: String) async -> Result { + do { + return try await .success(.init(spaceID: spaceID, leaveHandle: spaceService.leaveSpace(spaceId: spaceID))) + } catch { + MXLog.error("Failed to get leave handle for \(spaceID): \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Private private func handleUpdates(_ updates: [SpaceListUpdate]) { diff --git a/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift b/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift index e874dfbe1..6ad956b58 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift @@ -17,4 +17,5 @@ protocol SpaceServiceProxyProtocol { var joinedSpacesPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> { get } func spaceRoomList(spaceID: String, parent: SpaceRoomProxyProtocol?) async -> Result + func leaveSpace(spaceID: String) async -> Result } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index a1b795053..e443667bd 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -365,6 +365,12 @@ extension PreviewTests { } } + func testLeaveSpaceView() async throws { + for (index, preview) in LeaveSpaceView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + func testLegalInformationScreen() async throws { for (index, preview) in LegalInformationScreen_Previews._allPreviews.enumerated() { try await assertSnapshots(matching: preview, step: index) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-en-GB.png new file mode 100644 index 000000000..653e1d95f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8919d537dacbfa092cf8bb0bd088d3d9ce1c2acef978675e9d7a1ec9aae8ad1 +size 75096 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-pseudo.png new file mode 100644 index 000000000..ad29b61ad --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43b9a0739c68efb744640f6a23ebb59aadd4bcc8c5244fa3c47cab7ab427949b +size 75068 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-en-GB.png new file mode 100644 index 000000000..5b70cb4bc --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f62ab49653a17483a0b0ec3237c8aac8af7d7cf50a9f9b6e5def24a63e1c87c +size 33417 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-pseudo.png new file mode 100644 index 000000000..fdd0d8a98 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Many-Rooms-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53f544407b29a510cbfbe78f806a090c65554291018e8f6c202b968ac972105d +size 35337 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-en-GB.png new file mode 100644 index 000000000..a11283fdc --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18cbed2b974bbbd0a0e16f707c1d0b9eb14d72d8d638e3e9ef9f91da48423e29 +size 85863 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-pseudo.png new file mode 100644 index 000000000..a91c307ce --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83083ca194cd87ed33d7a4246436ac2561481519ae7a440ffa2d4a50cb131381 +size 89816 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-en-GB.png new file mode 100644 index 000000000..3e5c92e26 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24805390f21ccaccc00622e1ef99efbe3ce8a514a944fdc5b95060eaef247f32 +size 43837 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-pseudo.png new file mode 100644 index 000000000..5fdd93c69 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.No-Rooms-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce1e9ab48a3edb5d4322c04d4c1ccbe16f5188db6a00fd248cadf1d962a43645 +size 51116 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-en-GB.png new file mode 100644 index 000000000..ad0aba3b7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec9c8975b0fa564d212ec2241804aaa287901a557ca93d5503d357073fd2a98c +size 151507 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-pseudo.png new file mode 100644 index 000000000..3e23a1db4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bffa459bce40305f442d5babae093b6611dff474e173fe194ab0bb24e3b3c649 +size 163522 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-en-GB.png new file mode 100644 index 000000000..0724528a5 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e74a92933da47909b7fd57525936e7042b73412d269d97b162bfaf92947a847 +size 100719 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-pseudo.png new file mode 100644 index 000000000..7d7d3d48e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/leaveSpaceView.Only-Admin-Rooms-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28db91dd1882c36c5dba6ac2c79727c06d60865eae40226fb4f16981d040a1fa +size 119047 diff --git a/UnitTests/Sources/SpaceScreenViewModelTests.swift b/UnitTests/Sources/SpaceScreenViewModelTests.swift index c53b3f244..42e6bfad7 100644 --- a/UnitTests/Sources/SpaceScreenViewModelTests.swift +++ b/UnitTests/Sources/SpaceScreenViewModelTests.swift @@ -17,6 +17,7 @@ class SpaceScreenViewModelTests: XCTestCase { let mockSpaceRooms = [SpaceRoomProxyProtocol].mockSpaceList var clientProxy: ClientProxyMock! var paginationStateSubject: CurrentValueSubject = .init(.idle(endReached: true)) + var rustLeaveHandle: LeaveSpaceHandleSDKMock! var viewModel: SpaceScreenViewModelProtocol! @@ -192,6 +193,37 @@ class SpaceScreenViewModelTests: XCTestCase { } } + func testLeavingSpace() async throws { + setupViewModel() + XCTAssertNil(context.leaveHandle) + + let deferredHandle = deferFulfillment(context.observe(\.leaveHandle)) { $0 != nil } + context.send(viewAction: .leaveSpace) + try await deferredHandle.fulfill() + XCTAssertNotNil(context.leaveHandle, "The leave action should show the leave view.") + + let handle = try XCTUnwrap(context.leaveHandle) + let selectedCount = handle.selectedCount + let firstSelectedRoom = try XCTUnwrap(handle.rooms.first { $0.isSelected }) + XCTAssertGreaterThan(selectedCount, 0, "The leave view should have selected rooms to begin with") + + context.send(viewAction: .deselectAllLeaveRoomDetails) + XCTAssertEqual(handle.selectedCount, 0, "Deselecting all should result in no selected rooms.") + + context.send(viewAction: .toggleLeaveSpaceRoomDetails(id: firstSelectedRoom.spaceRoomProxy.id)) + XCTAssertEqual(handle.selectedCount, 1, "Toggling a room should result in 1 selected room") + + // Confirming the leave should leave the selected room and then the space. + let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLeftSpace } + context.send(viewAction: .confirmLeaveSpace) + try await deferredAction.fulfill() + XCTAssertNil(context.leaveHandle) + XCTAssertTrue(rustLeaveHandle.leaveRoomIdsCalled) + XCTAssertEqual(rustLeaveHandle.leaveRoomIdsReceivedRoomIds, + [firstSelectedRoom.spaceRoomProxy.id, spaceRoomListProxy.spaceRoomProxy.id], + "Confirming the leave should first leave the selected room and then the space.") + } + // MARK: - Helpers private func setupViewModel(paginationResponses: [[SpaceRoomProxyProtocol]] = []) { @@ -204,6 +236,11 @@ class SpaceScreenViewModelTests: XCTestCase { guard let spaceRoomProxy = mockSpaceRooms.first(where: { $0.id == spaceID }) else { return .failure(.missingSpace) } return .success(SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy))) } + let rustLeaveHandle = LeaveSpaceHandleSDKMock(.init()) + spaceServiceProxy.leaveSpaceSpaceIDClosure = { spaceID in + .success(LeaveSpaceHandleProxy(spaceID: spaceID, leaveHandle: rustLeaveHandle)) + } + self.rustLeaveHandle = rustLeaveHandle clientProxy = ClientProxyMock(.init()) @@ -214,3 +251,12 @@ class SpaceScreenViewModelTests: XCTestCase { userIndicatorController: UserIndicatorControllerMock()) } } + +private extension SpaceScreenViewModelAction { + var isLeftSpace: Bool { + switch self { + case .leftSpace: true + default: false + } + } +} diff --git a/project.yml b/project.yml index 553dd1f93..e11eab633 100644 --- a/project.yml +++ b/project.yml @@ -68,7 +68,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 25.09.26 + exactVersion: 25.10.02 # path: ../matrix-rust-sdk Compound: path: compound-ios