From b009b8ed669ef71ced31b972345cf81e7bbf64f8 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:29:27 +0000 Subject: [PATCH] Add the initial implementation for managing spaces. (#4963) This isn't hooked up into the flows yet. --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 4 + .../en-US.lproj/Localizable.strings | 3 + .../en.lproj/Localizable.strings | 3 + .../en.lproj/Localizable.stringsdict | 32 +++++++ ElementX/Sources/Generated/Strings.swift | 16 ++++ .../Mocks/Generated/GeneratedMocks.swift | 70 ++++++++++++++++ .../Sources/Mocks/SpaceServiceProxyMock.swift | 1 + .../Sources/Other/SwiftUI/Backports.swift | 22 +++++ .../Other/SwiftUI/Views/ToolbarButton.swift | 27 +++--- .../TestablePreviewsDictionary.swift | 1 + .../Screens/Spaces/Common/SpaceRoomCell.swift | 84 +++++++++++++------ .../View/LeaveSpaceRoomDetailsCell.swift | 2 +- .../SpaceScreen/SpaceScreenModels.swift | 24 +++++- .../SpaceScreen/SpaceScreenViewModel.swift | 56 ++++++++++++- .../SpaceRemoveChildrenConfirmationView.swift | 61 ++++++++++++++ .../Spaces/SpaceScreen/View/SpaceScreen.swift | 64 ++++++++++++-- .../Services/Spaces/SpaceServiceProxy.swift | 9 ++ .../Spaces/SpaceServiceProxyProtocol.swift | 1 + .../Sources/GeneratedPreviewTests.swift | 6 ++ ...eChildrenConfirmationView.iPad-en-GB-0.png | 3 + ...ChildrenConfirmationView.iPad-pseudo-0.png | 3 + ...hildrenConfirmationView.iPhone-en-GB-0.png | 3 + ...ildrenConfirmationView.iPhone-pseudo-0.png | 3 + .../spaceRoomCell.iPad-en-GB-0.png | 4 +- .../spaceRoomCell.iPad-pseudo-0.png | 4 +- .../spaceRoomCell.iPhone-en-GB-0.png | 4 +- .../spaceRoomCell.iPhone-pseudo-0.png | 4 +- .../spaceScreen.Managing-iPad-en-GB.png | 3 + .../spaceScreen.Managing-iPad-pseudo.png | 3 + .../spaceScreen.Managing-iPhone-en-GB.png | 3 + .../spaceScreen.Managing-iPhone-pseudo.png | 3 + .../Sources/SpaceScreenViewModelTests.swift | 64 +++++++++++++- 33 files changed, 529 insertions(+), 65 deletions(-) create mode 100644 ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceRemoveChildrenConfirmationView.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-pseudo.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 39c83cc86..7a5ef8f1b 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -639,6 +639,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "SpaceHeaderView_Previews") } + func testSpaceRemoveChildrenConfirmationView() async throws { + try await performAccessibilityAudit(named: "SpaceRemoveChildrenConfirmationView_Previews") + } + func testSpaceRoomCell() async throws { try await performAccessibilityAudit(named: "SpaceRoomCell_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 22918ef20..f6bc348fd 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -314,6 +314,7 @@ 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; 36AD4DD4C798E22584ED3200 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; 36CD6E11B37396E14F032CB6 /* SentrySwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 75361A9D8A3C5501EADB225D /* SentrySwiftUI */; }; + 36DCA11EA7320A45A4CC8D07 /* SpaceRemoveChildrenConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AC61A375CC2E47938B5D12 /* SpaceRemoveChildrenConfirmationView.swift */; }; 36DE961B784087D5E18EF9BA /* LogViewerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A07692536D66E3DA32C4964 /* LogViewerScreen.swift */; }; 370AF5BFCD4384DD455479B6 /* ElementCallWidgetDriverProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */; }; 37906355E207DB5703754675 /* AppLockSetupBiometricsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F893F4A111CB7BA5C96949 /* AppLockSetupBiometricsScreenViewModel.swift */; }; @@ -1924,6 +1925,7 @@ 43C2067FF58B4996323EB40C /* SessionDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectories.swift; sourceTree = ""; }; 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = ""; }; 44ABA63DBE7F76C58260B43B /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.swift; sourceTree = ""; }; + 44AC61A375CC2E47938B5D12 /* SpaceRemoveChildrenConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRemoveChildrenConfirmationView.swift; sourceTree = ""; }; 44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; 44B71F6D9062E8EB8929BB97 /* KnockRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestCell.swift; sourceTree = ""; }; 44C314C00533E2C297796B60 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -6712,6 +6714,7 @@ FA1D480A302295CFC3582543 /* View */ = { isa = PBXGroup; children = ( + 44AC61A375CC2E47938B5D12 /* SpaceRemoveChildrenConfirmationView.swift */, 646B50583A2CE6DA67F7739A /* SpaceScreen.swift */, ); path = View; @@ -8587,6 +8590,7 @@ C8E11A335456FCF94A744E6E /* SpaceFlowCoordinator.swift in Sources */, BF523D9E12E3C4AABBA2F6CB /* SpaceHeaderTopicSheetView.swift in Sources */, E9B4742B3D6E103327466513 /* SpaceHeaderView.swift in Sources */, + 36DCA11EA7320A45A4CC8D07 /* SpaceRemoveChildrenConfirmationView.swift in Sources */, 9B84F55288AB98783C11CC49 /* SpaceRoomCell.swift in Sources */, 913BCABB99796F82E3D8D93D /* SpaceRoomInfoMock.swift in Sources */, 0DAB08C117E0391F3ADB2031 /* SpaceRoomListProxy.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 083b181c1..76ef839ca 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -276,6 +276,7 @@ "common_reason" = "Reason"; "common_recovery_key" = "Recovery key"; "common_refreshing" = "Refreshing…"; +"common_removing" = "Removing…"; "common_replying_to" = "Replying to %1$@"; "common_report_a_bug" = "Report a bug"; "common_report_a_problem" = "Report a problem"; @@ -718,6 +719,8 @@ "screen_security_and_privacy_room_visibility_section_header" = "Visibility"; "screen_security_and_privacy_title" = "Security & privacy"; "screen_space_menu_action_members" = "View members"; +"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security."; +"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?"; "screen_space_add_rooms_room_access_description" = "Adding a room will not affect the room access. To change the access go to Room info > Privacy & security."; "screen_space_announcement_item1" = "View spaces you've created or joined"; "screen_space_announcement_item2" = "Accept or decline invites to spaces"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 2b4ab3024..2e1b38aa3 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -276,6 +276,7 @@ "common_reason" = "Reason"; "common_recovery_key" = "Recovery key"; "common_refreshing" = "Refreshing…"; +"common_removing" = "Removing…"; "common_replying_to" = "Replying to %1$@"; "common_report_a_bug" = "Report a bug"; "common_report_a_problem" = "Report a problem"; @@ -718,6 +719,8 @@ "screen_security_and_privacy_room_visibility_section_header" = "Visibility"; "screen_security_and_privacy_title" = "Security & privacy"; "screen_space_menu_action_members" = "View members"; +"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security."; +"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?"; "screen_space_add_rooms_room_access_description" = "Adding a room will not affect the room access. To change the access go to Room info > Privacy & security."; "screen_space_announcement_item1" = "View spaces you've created or joined"; "screen_space_announcement_item2" = "Accept or decline invites to spaces"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index 7f45767ad..09ac63ad4 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -130,6 +130,22 @@ %1$d Rooms + common_selected_count + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %1$d selected + other + %1$d selected + + common_spaces NSStringLocalizedFormatKey @@ -450,6 +466,22 @@ %1$@ are typing + screen_space_remove_rooms_confirmation_title + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Remove %1$d room from %2$@ + other + Remove %1$d rooms from %2$@ + + troubleshoot_notifications_test_blocked_users_result_some NSStringLocalizedFormatKey diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 2a0628375..ddc1c0985 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -620,6 +620,8 @@ internal enum L10n { internal static var commonRecoveryKey: String { return L10n.tr("Localizable", "common_recovery_key") } /// Refreshing… internal static var commonRefreshing: String { return L10n.tr("Localizable", "common_refreshing") } + /// Removing… + internal static var commonRemoving: String { return L10n.tr("Localizable", "common_removing") } /// Plural format key: "%#@COUNT@" internal static func commonReplies(_ p1: Int) -> String { return L10n.tr("Localizable", "common_replies", p1) @@ -660,6 +662,10 @@ internal enum L10n { internal static var commonSecurity: String { return L10n.tr("Localizable", "common_security") } /// Seen by internal static var commonSeenBy: String { return L10n.tr("Localizable", "common_seen_by") } + /// Plural format key: "%#@COUNT@" + internal static func commonSelectedCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "common_selected_count", p1) + } /// Send to internal static var commonSendTo: String { return L10n.tr("Localizable", "common_send_to") } /// Sending… @@ -3155,6 +3161,16 @@ internal enum L10n { internal static var screenSpaceListTitle: String { return L10n.tr("Localizable", "screen_space_list_title") } /// View members internal static var screenSpaceMenuActionMembers: String { return L10n.tr("Localizable", "screen_space_menu_action_members") } + /// Removing a room will not affect the room access. To change the access go to Room info > Privacy & security. + internal static var screenSpaceRemoveRoomsConfirmationContent: String { return L10n.tr("Localizable", "screen_space_remove_rooms_confirmation_content") } + /// Plural format key: "%#@COUNT@" + internal static func screenSpaceRemoveRoomsConfirmationTitle(_ p1: Int) -> String { + return L10n.tr("Localizable", "screen_space_remove_rooms_confirmation_title", p1) + } + /// Remove rooms from %1$@? + internal static func screenSpaceRemoveRoomsConfirmationTitleIos(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_space_remove_rooms_confirmation_title_ios", String(describing: p1)) + } /// Leave space internal static var screenSpaceSettingsLeaveSpace: String { return L10n.tr("Localizable", "screen_space_settings_leave_space") } /// Roles & permissions diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7aa984d3c..1c74e8767 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -17130,6 +17130,76 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable { return addChildToReturnValue } } + //MARK: - removeChild + + var removeChildFromUnderlyingCallsCount = 0 + var removeChildFromCallsCount: Int { + get { + if Thread.isMainThread { + return removeChildFromUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = removeChildFromUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + removeChildFromUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + removeChildFromUnderlyingCallsCount = newValue + } + } + } + } + var removeChildFromCalled: Bool { + return removeChildFromCallsCount > 0 + } + var removeChildFromReceivedArguments: (childID: String, spaceID: String)? + var removeChildFromReceivedInvocations: [(childID: String, spaceID: String)] = [] + + var removeChildFromUnderlyingReturnValue: Result! + var removeChildFromReturnValue: Result! { + get { + if Thread.isMainThread { + return removeChildFromUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = removeChildFromUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + removeChildFromUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + removeChildFromUnderlyingReturnValue = newValue + } + } + } + } + var removeChildFromClosure: ((String, String) async -> Result)? + + func removeChild(_ childID: String, from spaceID: String) async -> Result { + removeChildFromCallsCount += 1 + removeChildFromReceivedArguments = (childID: childID, spaceID: spaceID) + DispatchQueue.main.async { + self.removeChildFromReceivedInvocations.append((childID: childID, spaceID: spaceID)) + } + if let removeChildFromClosure = removeChildFromClosure { + return await removeChildFromClosure(childID, spaceID) + } else { + return removeChildFromReturnValue + } + } } class SpaceServiceRoomMock: SpaceServiceRoomProtocol, @unchecked Sendable { var id: String { diff --git a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift index 704c10586..2c84e8681 100644 --- a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift +++ b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift @@ -39,6 +39,7 @@ extension SpaceServiceProxyMock { .success(configuration.topLevelSpaces.first { $0.id == spaceID }) } addChildToReturnValue = .success(()) + removeChildFromReturnValue = .success(()) } } diff --git a/ElementX/Sources/Other/SwiftUI/Backports.swift b/ElementX/Sources/Other/SwiftUI/Backports.swift index f990e584a..e037e2b07 100644 --- a/ElementX/Sources/Other/SwiftUI/Backports.swift +++ b/ElementX/Sources/Other/SwiftUI/Backports.swift @@ -39,6 +39,28 @@ extension View { self } } + + @ViewBuilder func backportButtonStyleGlass() -> some View { + if #available(iOS 26, *) { + buttonStyle(.glass) + } else { + self + } + } + + @ViewBuilder func backportButtonStyleGlassProminent() -> some View { + if #available(iOS 26, *) { + // `.glassProminent` breaks our preview tests so we need to disable it when running tests. + // https://github.com/pointfreeco/swift-snapshot-testing/issues/1029#issuecomment-3366942138 + if ProcessInfo.isRunningTests { + self + } else { + buttonStyle(.glassProminent) + } + } else { + buttonStyle(.borderedProminent) + } + } } extension ToolbarContent { diff --git a/ElementX/Sources/Other/SwiftUI/Views/ToolbarButton.swift b/ElementX/Sources/Other/SwiftUI/Views/ToolbarButton.swift index 8827c211e..f11a5603e 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/ToolbarButton.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/ToolbarButton.swift @@ -16,10 +16,11 @@ struct ToolbarButton: View { case cancel(title: String) case confirm(title: String) + case destructive(title: String) var title: String { switch self { - case .cancel(let title), .confirm(let title): + case .cancel(let title), .confirm(let title), .destructive(let title): title } } @@ -33,6 +34,9 @@ struct ToolbarButton: View { case .confirm: CompoundIcon(\.check) .foregroundStyle(.compound.iconOnSolidPrimary) + case .destructive: + CompoundIcon(\.delete) + .foregroundStyle(.compound.iconOnSolidPrimary) } } @@ -42,6 +46,8 @@ struct ToolbarButton: View { .compound.bgCanvasDefault case .confirm: .compound.bgAccentRest + case .destructive: + .compound.bgCriticalPrimary } } } @@ -56,27 +62,13 @@ struct ToolbarButton: View { .accessibilityLabel(role.title) } .tint(role.tint) - .buttonStyleGlassProminent() + .backportButtonStyleGlassProminent() } else { Button(role.title, action: action) } } } -@available(iOS 26, *) -private extension View { - @ViewBuilder - func buttonStyleGlassProminent() -> some View { - // `.glassProminent` breaks our preview tests so we need to disable it when running tests. - // https://github.com/pointfreeco/swift-snapshot-testing/issues/1029#issuecomment-3366942138 - if ProcessInfo.isRunningTests { - self - } else { - buttonStyle(.glassProminent) - } - } -} - struct ToolbarButton_Previews: PreviewProvider, TestablePreview { static var previews: some View { NavigationStack { @@ -88,6 +80,9 @@ struct ToolbarButton_Previews: PreviewProvider, TestablePreview { ToolbarItem(placement: .cancellationAction) { ToolbarButton(role: .cancel) { } } + ToolbarItem(placement: .primaryAction) { + ToolbarButton(role: .destructive(title: L10n.actionRemove)) { } + } } } } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index c4081573d..d207f3842 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -167,6 +167,7 @@ enum TestablePreviewsDictionary { "SpaceAddRoomsScreen_Previews" : SpaceAddRoomsScreen_Previews.self, "SpaceHeaderTopicSheetView_Previews" : SpaceHeaderTopicSheetView_Previews.self, "SpaceHeaderView_Previews" : SpaceHeaderView_Previews.self, + "SpaceRemoveChildrenConfirmationView_Previews" : SpaceRemoveChildrenConfirmationView_Previews.self, "SpaceRoomCell_Previews" : SpaceRoomCell_Previews.self, "SpaceScreen_Previews" : SpaceScreen_Previews.self, "SpaceSettingsScreen_Previews" : SpaceSettingsScreen_Previews.self, diff --git a/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift b/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift index c38a69151..a4b116424 100644 --- a/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift +++ b/ElementX/Sources/Screens/Spaces/Common/SpaceRoomCell.swift @@ -11,7 +11,8 @@ import Compound import SwiftUI struct SpaceRoomCell: View { - @Environment(\.dynamicTypeSize) var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.editMode) private var editMode let spaceServiceRoom: SpaceServiceRoomProtocol let isSelected: Bool @@ -24,6 +25,9 @@ struct SpaceRoomCell: View { private let verticalInsets = 12.0 private let horizontalInsets = 16.0 + private var isEditModeActive: Bool { editMode?.wrappedValue ?? .inactive != .inactive } + private var isHighlighted: Bool { isSelected && !isEditModeActive } + private var subtitle: String { if spaceServiceRoom.isSpace { switch spaceServiceRoom.visibility { @@ -58,22 +62,34 @@ struct SpaceRoomCell: View { Button { action(.select(spaceServiceRoom)) } label: { - HStack(spacing: 16.0) { - avatar - - content - .padding(.vertical, verticalInsets) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.compound.borderDisabled) - .frame(height: 1 / UIScreen.main.scale) - .padding(.trailing, -horizontalInsets) + HStack(spacing: 0) { + if isEditModeActive { + ZStack { + ListRowAccessory.multiSelection(isSelected) } + // Use padding rather than spacing to improve the animation. + .padding(.trailing, 16) + // Put the transition on a ZStack to prevent it from being applied during selection/deselection. + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + HStack(spacing: 16) { + avatar + + content + .padding(.vertical, verticalInsets) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.compound.borderDisabled) + .frame(height: 1 / UIScreen.main.scale) + .padding(.trailing, -horizontalInsets) + } + } } .padding(.horizontal, horizontalInsets) .accessibilityElement(children: .combine) } - .buttonStyle(SpaceRoomCellButtonStyle(isSelected: isSelected)) + .buttonStyle(SpaceRoomCellButtonStyle(isHighlighted: isHighlighted)) .accessibilityIdentifier(A11yIdentifiers.spacesScreen.spaceRoomName(spaceServiceRoom.name)) } @@ -146,13 +162,13 @@ struct SpaceRoomCell: View { } struct SpaceRoomCellButtonStyle: ButtonStyle { - let isSelected: Bool + let isHighlighted: Bool func makeBody(configuration: Configuration) -> some View { configuration.label - .background(isSelected || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault) + .background(isHighlighted || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault) .contentShape(Rectangle()) - .animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected) + .animation(isHighlighted ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isHighlighted) } } @@ -162,21 +178,35 @@ struct SpaceRoomCell_Previews: PreviewProvider, TestablePreview { static let spaces = [SpaceServiceRoomProtocol].mockSpaceList static var previews: some View { - VStack(spacing: 0) { - ForEach(spaces, id: \.id) { space in - SpaceRoomCell(spaceServiceRoom: space, + ScrollView { + VStack(spacing: 0) { + ForEach(spaces, id: \.id) { space in + SpaceRoomCell(spaceServiceRoom: space, + isSelected: false, + mediaProvider: mediaProvider) { _ in } + } + + SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Space being joined", isSpace: true)), isSelected: false, + isJoining: true, mediaProvider: mediaProvider) { _ in } + SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Room being joined", isSpace: false)), + isSelected: false, + isJoining: true, + mediaProvider: mediaProvider) { _ in } + + SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Selected", isSpace: false, state: .joined)), + isSelected: true, + isJoining: false, + mediaProvider: mediaProvider) { _ in } + .environment(\.editMode, .constant(.active)) + SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Unselected", isSpace: false, state: .joined)), + isSelected: false, + isJoining: false, + mediaProvider: mediaProvider) { _ in } + .environment(\.editMode, .constant(.active)) } - - SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Space being joined", isSpace: true)), - isSelected: false, - isJoining: true, - mediaProvider: mediaProvider) { _ in } - SpaceRoomCell(spaceServiceRoom: SpaceServiceRoomMock(.init(id: "Room being joined", isSpace: false)), - isSelected: false, - isJoining: true, - mediaProvider: mediaProvider) { _ in } } + .previewLayout(.fixed(width: 390, height: 850)) } } diff --git a/ElementX/Sources/Screens/Spaces/LeaveSpace/View/LeaveSpaceRoomDetailsCell.swift b/ElementX/Sources/Screens/Spaces/LeaveSpace/View/LeaveSpaceRoomDetailsCell.swift index 5a55e9748..7d4a8f92c 100644 --- a/ElementX/Sources/Screens/Spaces/LeaveSpace/View/LeaveSpaceRoomDetailsCell.swift +++ b/ElementX/Sources/Screens/Spaces/LeaveSpace/View/LeaveSpaceRoomDetailsCell.swift @@ -61,7 +61,7 @@ struct LeaveSpaceRoomDetailsCell: View { } .padding(.horizontal, 16) } - .buttonStyle(SpaceRoomCellButtonStyle(isSelected: false)) + .buttonStyle(SpaceRoomCellButtonStyle(isHighlighted: false)) } @ViewBuilder diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift index 86bbbb279..bdd4ba889 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift @@ -6,7 +6,7 @@ // Please see LICENSE files in the repository root for full details. // -import Foundation +import SwiftUI enum SpaceScreenViewModelAction { case selectSpace(SpaceRoomListProxyProtocol) @@ -33,14 +33,30 @@ struct SpaceScreenViewState: BindableState { var canEditRolesAndPermissions = false var canEditSecurityAndPrivacy = false + var editMode: EditMode = .inactive + var editModeSelectedIDs: Set = [] + + var bindings = SpaceScreenViewStateBindings() + + var visibleRooms: [SpaceServiceRoomProtocol] { + if editMode == .inactive { + rooms + } else { + rooms.filter { !$0.isSpace } + } + } + var isSpaceManagementEnabled: Bool { canEditBaseInfo || canEditRolesAndPermissions || canEditSecurityAndPrivacy } - var bindings = SpaceScreenViewStateBindings() + func isSpaceIDSelected(_ spaceID: String) -> Bool { + selectedSpaceRoomID == spaceID || editModeSelectedIDs.contains(spaceID) + } } struct SpaceScreenViewStateBindings { + var isPresentingRemoveChildrenConfirmation = false var leaveSpaceViewModel: LeaveSpaceViewModel? } @@ -49,4 +65,8 @@ enum SpaceScreenViewAction { case leaveSpace case spaceSettings(roomProxy: JoinedRoomProxyProtocol) case displayMembers(roomProxy: JoinedRoomProxyProtocol) + case manageChildren + case removeSelectedChildren + case confirmRemoveSelectedChildren + case finishManagingChildren } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift index fee2d7f6e..823e182b5 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift @@ -107,7 +107,7 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc MXLog.info("View model: received view action: \(viewAction)") switch viewAction { - case .spaceAction(.select(let spaceServiceRoom)): + case .spaceAction(.select(let spaceServiceRoom)) where state.editMode == .inactive: if spaceServiceRoom.isSpace { if spaceServiceRoom.state != .joined { actionsSubject.send(.selectUnjoinedSpace(spaceServiceRoom)) @@ -118,6 +118,14 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc // No need to check the join state, the room flow will show an appropriately configured join screen if needed. actionsSubject.send(.selectRoom(roomID: spaceServiceRoom.id)) } + case .spaceAction(.select(let spaceServiceRoom)): // isEditModeActive == true + withTransaction(\.disablesAnimations, true) { // The button adds an unwanted animation. + if state.editModeSelectedIDs.contains(spaceServiceRoom.id) { + state.editModeSelectedIDs.remove(spaceServiceRoom.id) + } else { + state.editModeSelectedIDs.insert(spaceServiceRoom.id) + } + } case .spaceAction(.join(let spaceServiceRoom)): Task { await join(spaceServiceRoom) } case .leaveSpace: @@ -126,6 +134,20 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc actionsSubject.send(.displayMembers(roomProxy: roomProxy)) case .spaceSettings(let roomProxy): actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy)) + case .manageChildren: + withAnimation(.easeOut(duration: 0.25).disabledDuringTests()) { + state.editMode = .transient + } + case .removeSelectedChildren: + state.bindings.isPresentingRemoveChildrenConfirmation = true + case .confirmRemoveSelectedChildren: + Task { await removeSelectedChildren() } + case .finishManagingChildren: + withAnimation(.easeOut(duration: 0.25).disabledDuringTests()) { + state.editMode = .inactive + } completion: { + self.state.editModeSelectedIDs.removeAll() + } } } @@ -158,6 +180,25 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc } } + private func removeSelectedChildren() async { + showRemovingIndicator() + defer { hideRemovingIndicator() } + + state.bindings.isPresentingRemoveChildrenConfirmation = false + + for childID in state.editModeSelectedIDs { + switch await spaceServiceProxy.removeChild(childID, from: spaceRoomListProxy.id) { + case .success: + MXLog.info("Successfully removed \(childID) from \(spaceRoomListProxy.id)") + case .failure: + showFailureIndicator() + return + } + } + + process(viewAction: .finishManagingChildren) + } + private func showLeaveSpaceConfirmation() async { guard case let .success(leaveHandle) = await spaceServiceProxy.leaveSpace(spaceID: spaceRoomListProxy.id) else { showFailureIndicator() @@ -192,9 +233,20 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc // MARK: - Indicators - private static var leavingIndicatorID: String { "\(Self.self)-Leaving" } + private static var removingIndicatorID: String { "\(Self.self)-Removing" } private static var failureIndicatorID: String { "\(Self.self)-Failure" } + private func showRemovingIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: Self.removingIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonRemoving, + persistent: true)) + } + + private func hideRemovingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.removingIndicatorID) + } + private func showFailureIndicator() { userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID, type: .toast, diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceRemoveChildrenConfirmationView.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceRemoveChildrenConfirmationView.swift new file mode 100644 index 000000000..972e67293 --- /dev/null +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceRemoveChildrenConfirmationView.swift @@ -0,0 +1,61 @@ +// +// Copyright 2025 Element Creations Ltd. +// 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 SpaceRemoveChildrenConfirmationView: View { + @Environment(\.dismiss) private var dismiss + + let spaceName: String + let action: () -> Void + + @State private var scrollViewHeight: CGFloat = .zero + @State private var buttonsHeight: CGFloat = .zero + private let topPadding = 19.0 + + var body: some View { + ScrollView { + TitleAndIcon(title: L10n.screenSpaceRemoveRoomsConfirmationTitleIos(spaceName), + subtitle: L10n.screenSpaceRemoveRoomsConfirmationContent, + icon: \.errorSolid, + iconStyle: .alertSolid) + .padding(24) + .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 buttons: some View { + VStack(spacing: 16) { + Button(L10n.actionRemove, role: .destructive, action: action) + .buttonStyle(.compound(.primary)) + + Button(L10n.actionCancel, action: dismiss.callAsFunction) + .buttonStyle(.compound(.tertiary)) + } + .padding(.horizontal, 16) + .padding(.top, 16) + } +} + +// MARK: - Previews + +struct SpaceRemoveChildrenConfirmationView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + SpaceRemoveChildrenConfirmationView(spaceName: "Company") { } + } +} diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift index a398c1292..7bdff3c06 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift @@ -12,19 +12,37 @@ import SwiftUI struct SpaceScreen: View { @Bindable var context: SpaceScreenViewModel.Context + private var isEditModeActive: Bool { context.viewState.editMode != .inactive } + var body: some View { ScrollView { LazyVStack(spacing: 0) { - SpaceHeaderView(spaceServiceRoom: context.viewState.space, - mediaProvider: context.mediaProvider) + if !isEditModeActive { + SpaceHeaderView(spaceServiceRoom: context.viewState.space, + mediaProvider: context.mediaProvider) + } + rooms } } + .environment(\.editMode, .constant(context.viewState.editMode)) .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .toolbarRole(RoomHeaderView.toolbarRole) + .toolbarRole(isEditModeActive ? .automatic : RoomHeaderView.toolbarRole) .navigationTitle(context.viewState.space.name) .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbar } + .navigationBarBackButtonHidden(isEditModeActive) + .toolbar { + if isEditModeActive { + editModeToolbar + } else { + toolbar + } + } + .sheet(isPresented: $context.isPresentingRemoveChildrenConfirmation) { + SpaceRemoveChildrenConfirmationView(spaceName: context.viewState.space.name) { + context.send(viewAction: .confirmRemoveSelectedChildren) + } + } .sheet(item: $context.leaveSpaceViewModel) { leaveSpaceViewModel in LeaveSpaceView(context: leaveSpaceViewModel.context) } @@ -32,9 +50,9 @@ struct SpaceScreen: View { @ViewBuilder var rooms: some View { - ForEach(context.viewState.rooms, id: \.id) { spaceServiceRoom in + ForEach(context.viewState.visibleRooms, id: \.id) { spaceServiceRoom in SpaceRoomCell(spaceServiceRoom: spaceServiceRoom, - isSelected: spaceServiceRoom.id == context.viewState.selectedSpaceRoomID, + isSelected: context.viewState.isSpaceIDSelected(spaceServiceRoom.id), isJoining: context.viewState.joiningRoomIDs.contains(spaceServiceRoom.id), mediaProvider: context.mediaProvider) { action in context.send(viewAction: .spaceAction(action)) @@ -100,20 +118,46 @@ struct SpaceScreen: View { } } } + + @ToolbarContentBuilder + var editModeToolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.actionCancel, role: .cancel) { + context.send(viewAction: .finishManagingChildren) + } + } + + ToolbarItem(placement: .principal) { + Text(L10n.commonSelectedCount(context.viewState.editModeSelectedIDs.count)) + } + + ToolbarItem(placement: .primaryAction) { + ToolbarButton(role: .destructive(title: L10n.actionRemove)) { + context.send(viewAction: .removeSelectedChildren) + } + .disabled(context.viewState.editModeSelectedIDs.isEmpty) + } + } } // MARK: - Previews struct SpaceScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = makeViewModel() + static let managingViewModel = makeViewModel(isManagingRooms: true) static var previews: some View { NavigationStack { SpaceScreen(context: viewModel.context) } + + NavigationStack { + SpaceScreen(context: managingViewModel.context) + } + .previewDisplayName("Managing") } - static func makeViewModel() -> SpaceScreenViewModel { + static func makeViewModel(isManagingRooms: Bool = false) -> SpaceScreenViewModel { let spaceServiceRoom = SpaceServiceRoomMock(.init(id: "!eng-space:matrix.org", name: "Engineering Team", isSpace: true, @@ -138,6 +182,12 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview { userSession: userSession, appSettings: AppSettings(), userIndicatorController: UserIndicatorControllerMock()) + + if isManagingRooms { + viewModel.state.editMode = .transient + viewModel.state.editModeSelectedIDs = [viewModel.state.visibleRooms[0].id] + } + return viewModel } } diff --git a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift index 2ebaad179..b20b05165 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift @@ -76,6 +76,15 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol { } } + func removeChild(_ childID: String, from spaceID: String) async -> Result { + do { + return try await .success(spaceService.removeChildFromSpace(childId: childID, spaceId: spaceID)) + } catch { + MXLog.error("Failed to remove child \(childID) to space \(spaceID)") + 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 b5c598b07..d7f746de1 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift @@ -26,4 +26,5 @@ protocol SpaceServiceProxyProtocol { /// Adds a room (or space) as a child of another space. func addChild(_ childID: String, to spaceID: String) async -> Result + func removeChild(_ childID: String, from spaceID: String) async -> Result } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 930a8bcc7..49d48c0d5 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -959,6 +959,12 @@ extension PreviewTests { } } + func testSpaceRemoveChildrenConfirmationView() async throws { + for (index, preview) in SpaceRemoveChildrenConfirmationView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + func testSpaceRoomCell() async throws { for (index, preview) in SpaceRoomCell_Previews._allPreviews.enumerated() { try await assertSnapshots(matching: preview, step: index) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-en-GB-0.png new file mode 100644 index 000000000..4cb5354cd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72c31c35472aa893e85ba0f69a57803f89b3d348fffa2db344ed10adad3fd0ce +size 100941 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-pseudo-0.png new file mode 100644 index 000000000..346e2630b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73207894263b484d9f3e403682296e6d878c0deeff29ab6d5a588bf9fe163fcd +size 116700 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-en-GB-0.png new file mode 100644 index 000000000..40025393a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25fed5d8d19b20c52e8f6a34d69f73e859edd0aa059ffc776fe11e6c682cb3b2 +size 58863 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-pseudo-0.png new file mode 100644 index 000000000..49b244588 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRemoveChildrenConfirmationView.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca068e3cc355f71fdec278ad93da2c334ef0b9b88579b8b32b8da631e12488b2 +size 82999 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png index 97c4d1373..59231a048 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cf108ead7145e11272bff432caa1d66350c1f3eb0868c64a5f49668a7558a49 -size 231595 +oid sha256:5511435ffbb116306de56c4fe13874f90cc12eabf83b2589ba9a1adad4080238 +size 253938 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png index 06bee9df5..6b6cfcc91 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e0652c568a9cc3ae05ab6d3ba23db1e4e9b4d0bc28f0f13ae5814acd1a2be0a -size 265636 +oid sha256:0dc15c6e2798e677ab80ec5ee6f2e22d32faddccdc47e2f52b433bad0ca4ec94 +size 296429 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-en-GB-0.png index 04e15f0b2..43f963304 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21dedad4b56e65f9b5871cb6f610d589ae68ed254c520ef4212fadc8bff06215 -size 167875 +oid sha256:34754363cd2c7886e42caba103759f6fdb63af2e28a23de910573d266d7f4b82 +size 193936 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-pseudo-0.png index 5c89d2ba4..2edeeb587 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceRoomCell.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f3a53df912658596e6a4b9fc37a85b3b882995c15a959677bfb92f6ec16fdad -size 196235 +oid sha256:dee1473c832480b2adf30bf5a1900ff3d94a55400d651baafabdef1ed0e445d2 +size 226188 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-en-GB.png new file mode 100644 index 000000000..4dabc33ed --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b68d646d3cc7e109b242dab25985c9311129f4625c61b0fceabfde8befbe2a2 +size 139161 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-pseudo.png new file mode 100644 index 000000000..6c5ee67fa --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:180b69501525210102f696b7d7e7c6030070b2fce796149040a8310758cd826a +size 153988 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-en-GB.png new file mode 100644 index 000000000..a8dda4099 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:525db300c8ddf0b4277f082d972338bbcddef87ccc12c72b615ed8dfce8dc367 +size 89218 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-pseudo.png new file mode 100644 index 000000000..e5f01260a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/spaceScreen.Managing-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df0e19c0abbd1b39e399f3b8f7d033fdf5bf8c5ce688688027fc7ed856e78d04 +size 97285 diff --git a/UnitTests/Sources/SpaceScreenViewModelTests.swift b/UnitTests/Sources/SpaceScreenViewModelTests.swift index 445391ae6..f0934c99d 100644 --- a/UnitTests/Sources/SpaceScreenViewModelTests.swift +++ b/UnitTests/Sources/SpaceScreenViewModelTests.swift @@ -16,6 +16,7 @@ import MatrixRustSDKMocks @MainActor class SpaceScreenViewModelTests: XCTestCase { var spaceRoomListProxy: SpaceRoomListProxyMock! + var spaceServiceProxy: SpaceServiceProxyMock! let mockSpaceRooms = [SpaceServiceRoomProtocol].mockSpaceList var clientProxy: ClientProxyMock! var paginationStateSubject: CurrentValueSubject = .init(.idle(endReached: true)) @@ -177,6 +178,64 @@ class SpaceScreenViewModelTests: XCTestCase { try await deferredState.fulfill() } + func testManageRoomsWithoutRemoving() async throws { + setupViewModel(initialSpaceRooms: mockSpaceRooms) + XCTAssertEqual(context.viewState.editMode, .inactive) + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty) + XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }) + + context.send(viewAction: .manageChildren) + XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.") + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") + XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") + + let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + XCTAssertFalse(context.viewState.isSpaceIDSelected(selectedRoom.id)) + context.send(viewAction: .spaceAction(.select(selectedRoom))) + XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 1, "The selected room should be included.") + XCTAssertTrue(context.viewState.isSpaceIDSelected(selectedRoom.id), "The room should be selected.") + + context.send(viewAction: .finishManagingChildren) + XCTAssertEqual(context.viewState.editMode, .inactive, "Cancelling should disable edit mode.") + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Cancelling should clear all selected rooms.") + XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Cancelling should restore the hidden spaces.") + + XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children when cancelling.") + } + + func testManageRoomsRemovingChildren() async throws { + setupViewModel(initialSpaceRooms: mockSpaceRooms) + XCTAssertEqual(context.viewState.editMode, .inactive) + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty) + XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }) + + context.send(viewAction: .manageChildren) + XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.") + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") + XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") + + let firstRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + let lastRoom = try XCTUnwrap(mockSpaceRooms.last { !$0.isSpace }, "There should be a room to select.") + XCTAssertNotEqual(firstRoom.id, lastRoom.id, "There should be more than one room in the list.") + context.send(viewAction: .spaceAction(.select(firstRoom))) + context.send(viewAction: .spaceAction(.select(lastRoom))) + XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 2, "The selected rooms should be included.") + + context.send(viewAction: .removeSelectedChildren) + XCTAssertTrue(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.") + XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children before confirming.") + + let deferred = deferFulfillment(context.observe(\.viewState.editMode)) { $0 == .inactive } + context.send(viewAction: .confirmRemoveSelectedChildren) + try await deferred.fulfill() + XCTAssertFalse(context.isPresentingRemoveChildrenConfirmation, "Confirming should dismiss the confirmation prompt.") + XCTAssertEqual(context.viewState.editMode, .inactive, "Confirming should disable edit mode when done.") + XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Confirming should clear all selected rooms when done.") + XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Confirming should restore the hidden spaces when done.") + + XCTAssertEqual(spaceServiceProxy.removeChildFromCallsCount, 2, "Each selected room should have been removed.") + } + func testLeavingSpace() async throws { setupViewModel() XCTAssertNil(context.leaveSpaceViewModel) @@ -211,12 +270,13 @@ class SpaceScreenViewModelTests: XCTestCase { // MARK: - Helpers - private func setupViewModel(paginationResponses: [[SpaceServiceRoomProtocol]] = []) { + private func setupViewModel(initialSpaceRooms: [SpaceServiceRoomProtocol] = [], paginationResponses: [[SpaceServiceRoomProtocol]] = []) { spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: SpaceServiceRoomMock(.init(isSpace: true)), + initialSpaceRooms: initialSpaceRooms, paginationStateSubject: paginationStateSubject, paginationResponses: paginationResponses)) - let spaceServiceProxy = SpaceServiceProxyMock(.init()) + spaceServiceProxy = SpaceServiceProxyMock(.init()) spaceServiceProxy.spaceRoomListSpaceIDClosure = { [mockSpaceRooms] spaceID in guard let spaceServiceRoom = mockSpaceRooms.first(where: { $0.id == spaceID }) else { return .failure(.missingSpace) } return .success(SpaceRoomListProxyMock(.init(spaceServiceRoom: spaceServiceRoom)))