From 2292f4bd4ff8ff364a39241ced99ebef7190fda3 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Tue, 25 Nov 2025 17:55:23 +0100 Subject: [PATCH] single space access implementation also added the copies for the single and multiple spaces selection --- .../en-US.lproj/Localizable.strings | 6 +- .../en.lproj/Localizable.strings | 6 +- ElementX/Sources/Generated/Strings.swift | 16 ++- .../Mocks/Generated/GeneratedMocks.swift | 70 +++++++++++ .../Sources/Mocks/SpaceServiceProxyMock.swift | 2 + .../SecurityAndPrivacyScreenModels.swift | 119 +++++++++++++++++- .../SecurityAndPrivacyScreenViewModel.swift | 80 +++++++++--- .../View/SecurityAndPrivacyScreen.swift | 68 +++++++--- .../Services/Spaces/SpaceServiceProxy.swift | 9 ++ .../Spaces/SpaceServiceProxyProtocol.swift | 2 + 10 files changed, 337 insertions(+), 41 deletions(-) diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 2737fbdec..dac88f799 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -648,11 +648,15 @@ "screen_security_and_privacy_encryption_section_footer" = "Once enabled, encryption cannot be disabled."; "screen_security_and_privacy_encryption_toggle_title" = "Enable end-to-end encryption"; "screen_security_and_privacy_room_access_anyone_option_description" = "Anyone can join."; +"screen_security_and_privacy_room_access_footer" = "Choose which spaces’ members can join this room without an invitation. %1$@"; +"screen_security_and_privacy_room_access_footer_manage_spaces_action" = "Manage spaces"; "screen_security_and_privacy_room_access_invite_only_option_description" = "Only invited people can join."; "screen_security_and_privacy_room_access_invite_only_option_title" = "Invite only"; "screen_security_and_privacy_room_access_section_header" = "Access"; -"screen_security_and_privacy_room_access_space_members_option_description" = "Spaces are not currently supported"; +"screen_security_and_privacy_room_access_space_members_option_multiple_parents_description" = "Anyone in authorized spaces can join."; +"screen_security_and_privacy_room_access_space_members_option_single_parent_description" = "Anyone in %1$@ can join."; "screen_security_and_privacy_room_access_space_members_option_title" = "Space members"; +"screen_security_and_privacy_room_access_space_members_option_unavailable_description" = "Spaces are not currently supported"; "screen_security_and_privacy_room_address_section_header" = "Address"; "screen_security_and_privacy_room_directory_visibility_section_footer" = "Allow for this room to be found by searching %1$@ public room directory"; "screen_security_and_privacy_room_directory_visibility_toggle_description" = "Allow to be found by searching the public directory."; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ca5815869..8609b5fc5 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -648,11 +648,15 @@ "screen_security_and_privacy_encryption_section_footer" = "Once enabled, encryption cannot be disabled."; "screen_security_and_privacy_encryption_toggle_title" = "Enable end-to-end encryption"; "screen_security_and_privacy_room_access_anyone_option_description" = "Anyone can join."; +"screen_security_and_privacy_room_access_footer" = "Choose which spaces’ members can join this room without an invitation. %1$@"; +"screen_security_and_privacy_room_access_footer_manage_spaces_action" = "Manage spaces"; "screen_security_and_privacy_room_access_invite_only_option_description" = "Only invited people can join."; "screen_security_and_privacy_room_access_invite_only_option_title" = "Invite only"; "screen_security_and_privacy_room_access_section_header" = "Access"; -"screen_security_and_privacy_room_access_space_members_option_description" = "Spaces are not currently supported"; +"screen_security_and_privacy_room_access_space_members_option_multiple_parents_description" = "Anyone in authorized spaces can join."; +"screen_security_and_privacy_room_access_space_members_option_single_parent_description" = "Anyone in %1$@ can join."; "screen_security_and_privacy_room_access_space_members_option_title" = "Space members"; +"screen_security_and_privacy_room_access_space_members_option_unavailable_description" = "Spaces are not currently supported"; "screen_security_and_privacy_room_address_section_header" = "Address"; "screen_security_and_privacy_room_directory_visibility_section_footer" = "Allow for this room to be found by searching %1$@ public room directory"; "screen_security_and_privacy_room_directory_visibility_toggle_description" = "Allow to be found by searching the public directory."; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 99887c9ed..d8ab05154 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -2768,16 +2768,28 @@ internal enum L10n { internal static var screenSecurityAndPrivacyRoomAccessAnyoneOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_anyone_option_description") } /// Anyone internal static var screenSecurityAndPrivacyRoomAccessAnyoneOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_anyone_option_title") } + /// Choose which spaces’ members can join this room without an invitation. %1$@ + internal static func screenSecurityAndPrivacyRoomAccessFooter(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_security_and_privacy_room_access_footer", String(describing: p1)) + } + /// Manage spaces + internal static var screenSecurityAndPrivacyRoomAccessFooterManageSpacesAction: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_footer_manage_spaces_action") } /// Only invited people can join. internal static var screenSecurityAndPrivacyRoomAccessInviteOnlyOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_invite_only_option_description") } /// Invite only internal static var screenSecurityAndPrivacyRoomAccessInviteOnlyOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_invite_only_option_title") } /// Access internal static var screenSecurityAndPrivacyRoomAccessSectionHeader: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_section_header") } - /// Spaces are not currently supported - internal static var screenSecurityAndPrivacyRoomAccessSpaceMembersOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_space_members_option_description") } + /// Anyone in authorized spaces can join. + internal static var screenSecurityAndPrivacyRoomAccessSpaceMembersOptionMultipleParentsDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_space_members_option_multiple_parents_description") } + /// Anyone in %1$@ can join. + internal static func screenSecurityAndPrivacyRoomAccessSpaceMembersOptionSingleParentDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_security_and_privacy_room_access_space_members_option_single_parent_description", String(describing: p1)) + } /// Space members internal static var screenSecurityAndPrivacyRoomAccessSpaceMembersOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_space_members_option_title") } + /// Spaces are not currently supported + internal static var screenSecurityAndPrivacyRoomAccessSpaceMembersOptionUnavailableDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_space_members_option_unavailable_description") } /// You’ll need an address in order to make it visible in the public directory. internal static var screenSecurityAndPrivacyRoomAddressSectionFooter: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_address_section_footer") } /// Address diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index bdf8c9077..435e5ea3e 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -16616,6 +16616,76 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable { return leaveSpaceSpaceIDReturnValue } } + //MARK: - joinedParents + + var joinedParentsRoomIDUnderlyingCallsCount = 0 + var joinedParentsRoomIDCallsCount: Int { + get { + if Thread.isMainThread { + return joinedParentsRoomIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = joinedParentsRoomIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + joinedParentsRoomIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + joinedParentsRoomIDUnderlyingCallsCount = newValue + } + } + } + } + var joinedParentsRoomIDCalled: Bool { + return joinedParentsRoomIDCallsCount > 0 + } + var joinedParentsRoomIDReceivedRoomID: String? + var joinedParentsRoomIDReceivedInvocations: [String] = [] + + var joinedParentsRoomIDUnderlyingReturnValue: Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError>! + var joinedParentsRoomIDReturnValue: Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError>! { + get { + if Thread.isMainThread { + return joinedParentsRoomIDUnderlyingReturnValue + } else { + var returnValue: Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError>? = nil + DispatchQueue.main.sync { + returnValue = joinedParentsRoomIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + joinedParentsRoomIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + joinedParentsRoomIDUnderlyingReturnValue = newValue + } + } + } + } + var joinedParentsRoomIDClosure: ((String) async -> Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError>)? + + func joinedParents(roomID: String) async -> Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError> { + joinedParentsRoomIDCallsCount += 1 + joinedParentsRoomIDReceivedRoomID = roomID + DispatchQueue.main.async { + self.joinedParentsRoomIDReceivedInvocations.append(roomID) + } + if let joinedParentsRoomIDClosure = joinedParentsRoomIDClosure { + return await joinedParentsRoomIDClosure(roomID) + } else { + return joinedParentsRoomIDReturnValue + } + } } class StaticRoomSummaryProviderMock: StaticRoomSummaryProviderProtocol, @unchecked Sendable { var statePublisher: CurrentValuePublisher { diff --git a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift index 73d006f13..b8794140c 100644 --- a/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift +++ b/ElementX/Sources/Mocks/SpaceServiceProxyMock.swift @@ -13,6 +13,7 @@ import MatrixRustSDK extension SpaceServiceProxyMock { struct Configuration { var joinedSpaces: [SpaceRoomProxyProtocol] = [] + var joinedParentSpaces: [SpaceRoomProxyProtocol] = [] var spaceRoomLists: [String: SpaceRoomListProxyMock] = [:] var leaveSpaceRooms: [LeaveSpaceRoom] = [] } @@ -21,6 +22,7 @@ extension SpaceServiceProxyMock { self.init() joinedSpacesPublisher = .init(configuration.joinedSpaces) + joinedParentsRoomIDReturnValue = .success(configuration.joinedParentSpaces) spaceRoomListSpaceIDClosure = { spaceID in if let spaceRoomList = configuration.spaceRoomLists[spaceID] { .success(spaceRoomList) diff --git a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenModels.swift b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenModels.swift index 6de63c992..2df859168 100644 --- a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenModels.swift +++ b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenModels.swift @@ -7,23 +7,45 @@ // import Foundation +import MatrixRustSDK enum SecurityAndPrivacyScreenViewModelAction { case displayEditAddressScreen } struct SecurityAndPrivacyScreenViewState: BindableState { + private static let accessSectionFooterAttributedString = { + let linkPlaceholder = "{link}" + var footer = AttributedString(L10n.screenSecurityAndPrivacyRoomAccessFooter(linkPlaceholder)) + var linkString = AttributedString(L10n.screenSecurityAndPrivacyRoomAccessFooterManageSpacesAction) + // Doesn't really matter + linkString.link = .init(stringLiteral: "action://manageSpace") + linkString.bold() + footer.replace(linkPlaceholder, with: linkString) + return footer + }() + let serverName: String var currentSettings: SecurityAndPrivacySettings var bindings: SecurityAndPrivacyScreenViewStateBindings var canonicalAlias: String? var isKnockingEnabled: Bool + var isSpaceSettingsEnabled: Bool var isSpace: Bool var canEditAddress = false var canEditJoinRule = false var canEnableEncryption = false var canEditHistoryVisibility = false + var joinedParentSpaces: [SpaceRoomProxyProtocol] = [] + + var selectableSpacesCount: Int { + Set(joinedParentSpaces.map(\.id) + currentSettings.accessType.spaceIDs).count + } + + private var desiredJoinedParentSpaces: [SpaceRoomProxyProtocol] { + joinedParentSpaces.filter { bindings.desiredSettings.accessType.spaceIDs.contains($0.id) } + } private var hasChanges: Bool { currentSettings != bindings.desiredSettings @@ -45,16 +67,77 @@ struct SecurityAndPrivacyScreenViewState: BindableState { } return options } + + var isSpaceMembersOptionAvailable: Bool { + currentSettings.accessType.isSpaceUsers || isSpaceMembersOptionSelectable + } + + var isSpaceMembersOptionSelectable: Bool { + isSpaceSettingsEnabled && selectableSpacesCount > 0 + } + + var spaceMembersDescription: String { + if isSpaceMembersOptionSelectable { + switch spaceSelection { + case .singleJoined(let joinedParentSpace): + L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionSingleParentDescription(joinedParentSpace.name) + case .singleUnknown(let id): + L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionSingleParentDescription(id) + case .multiple: + L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionMultipleParentsDescription + } + } else { + L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionUnavailableDescription + } + } + + var accessSectionFooter: AttributedString? { + if bindings.desiredSettings.accessType.isSpaceUsers, isSpaceMembersOptionSelectable, selectableSpacesCount > 1 { + Self.accessSectionFooterAttributedString + } else { + nil + } + } + + enum SpaceSelection { + /// There is only one available parent space for selection and is joined by the user + case singleJoined(SpaceRoomProxyProtocol) + /// There is only one available space for selection and is unknown to the user + case singleUnknown(id: String) + /// Multiple spaces are available for selection + case multiple + } + + var spaceSelection: SpaceSelection { + if selectableSpacesCount > 1 { + .multiple + } else if let desiredJoinedParent = desiredJoinedParentSpaces.first { + // The parent space is joined by the user and is also currently selected + .singleJoined(desiredJoinedParent) + } else if let joinedParent = joinedParentSpaces.first { + // The parent space is joined by the user but is not currently selected + .singleJoined(joinedParent) + } else if let unknownSpaceID = bindings.desiredSettings.accessType.spaceIDs.first { + // The space is not joined by the user but is currently selected + .singleUnknown(id: unknownSpaceID) + } else { + // Not reachable because it would mean the selectable spaces are more than 1 + // but are neither selected and/or joined parents. + fatalError("Not reachable") + } + } init(serverName: String, accessType: SecurityAndPrivacyRoomAccessType, isEncryptionEnabled: Bool, historyVisibility: SecurityAndPrivacyHistoryVisibility, isSpace: Bool, - isKnockingEnabled: Bool) { + isKnockingEnabled: Bool, + isSpaceSettingsEnabled: Bool) { self.serverName = serverName self.isKnockingEnabled = isKnockingEnabled self.isSpace = isSpace + self.isSpaceSettingsEnabled = isSpaceSettingsEnabled let settings = SecurityAndPrivacySettings(accessType: accessType, isEncryptionEnabled: isEncryptionEnabled, @@ -76,11 +159,39 @@ struct SecurityAndPrivacySettings: Equatable { var isVisibileInRoomDirectory: Bool? } -enum SecurityAndPrivacyRoomAccessType { +enum SecurityAndPrivacyRoomAccessType: Equatable { case inviteOnly case askToJoin + case askToJoinWithSpaceUsers(spaceIDs: [String]) case anyone - case spaceUsers + case spaceUsers(spaceIDs: [String]) + + var isSpaceUsers: Bool { + switch self { + case .spaceUsers: + true + default: + false + } + } + + var isAddressRequired: Bool { + switch self { + case .inviteOnly, .spaceUsers: + false + case .anyone, .askToJoin, .askToJoinWithSpaceUsers: + true + } + } + + var spaceIDs: [String] { + switch self { + case .spaceUsers(let spaceIDs), .askToJoinWithSpaceUsers(let spaceIDs): + return spaceIDs + case .inviteOnly, .askToJoin, .anyone: + return [] + } + } } enum SecurityAndPrivacyAlertType { @@ -91,6 +202,8 @@ enum SecurityAndPrivacyScreenViewAction { case save case tryUpdatingEncryption(Bool) case editAddress + case selectedSpaceMembersAccess + case manageSpaces } enum SecurityAndPrivacyHistoryVisibility { diff --git a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift index c40fe4f0c..9cf28aabb 100644 --- a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift +++ b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift @@ -37,7 +37,8 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, isEncryptionEnabled: roomProxy.infoPublisher.value.isEncrypted, historyVisibility: roomProxy.infoPublisher.value.historyVisibility.toSecurityAndPrivacyHistoryVisibility, isSpace: roomProxy.infoPublisher.value.isSpace, - isKnockingEnabled: appSettings.knockingEnabled)) + isKnockingEnabled: appSettings.knockingEnabled, + isSpaceSettingsEnabled: appSettings.spaceSettingsEnabled)) if let powerLevels = roomProxy.infoPublisher.value.powerLevels { setupPermissions(powerLevels: powerLevels) @@ -45,6 +46,14 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, setupRoomDirectoryVisibility() setupSubscriptions() + Task { + switch await clientProxy.spaceService.joinedParents(roomID: roomProxy.id) { + case .success(let joinedParentSpaces): + state.joinedParentSpaces = joinedParentSpaces + case .failure: + break + } + } } // MARK: - Public @@ -69,6 +78,11 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, } case .editAddress: actionsSubject.send(.displayEditAddressScreen) + case .selectedSpaceMembersAccess: + handleSelectedSpaceMembersAccess() + case .manageSpaces: + // TODO: Implement multiple space selection + break } } @@ -127,6 +141,10 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, appSettings.$knockingEnabled .weakAssign(to: \.state.isKnockingEnabled, on: self) .store(in: &cancellables) + + appSettings.$spaceSettingsEnabled + .weakAssign(to: \.state.isSpaceSettingsEnabled, on: self) + .store(in: &cancellables) } private func setupPermissions(powerLevels: RoomPowerLevelsProxyProtocol) { @@ -202,6 +220,18 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, } } + private func handleSelectedSpaceMembersAccess() { + switch context.viewState.spaceSelection { + case .singleJoined(let joinedParent): + context.desiredSettings.accessType = .spaceUsers(spaceIDs: [joinedParent.id]) + case .singleUnknown(let id): + context.desiredSettings.accessType = .spaceUsers(spaceIDs: [id]) + case .multiple: + // TODO: Implement multiple space selection + break + } + } + private static let loadingIndicatorIdentifier = "\(EditRoomAddressScreenViewModel.self)-Loading" private func showLoadingIndicator() { @@ -225,25 +255,10 @@ private extension SecurityAndPrivacyRoomAccessType { .knock case .anyone: .public - case .spaceUsers: - fatalError("The user shouldn't be able to select this rule") - } - } -} - -private extension Optional where Wrapped == JoinRule { - var toSecurityAndPrivacyRoomAccessType: SecurityAndPrivacyRoomAccessType { - switch self { - case .none, .public: - return .anyone - case .invite: - return .inviteOnly - case .knock, .knockRestricted: - return .askToJoin - case .restricted: - return .spaceUsers - default: - return .inviteOnly + case .spaceUsers(let spaceIDs): + .restricted(rules: spaceIDs.map { .roomMembership(roomId: $0) }) + case .askToJoinWithSpaceUsers(let spaceIDs): + .knockRestricted(rules: spaceIDs.map { .roomMembership(roomId: $0) }) } } } @@ -273,3 +288,28 @@ private extension SecurityAndPrivacyHistoryVisibility { } } } + +private extension Optional where Wrapped == JoinRule { + var toSecurityAndPrivacyRoomAccessType: SecurityAndPrivacyRoomAccessType { + switch self { + case .none, .public: + return .anyone + case .invite: + return .inviteOnly + case .knock, .knockRestricted: + // TODO: Handle knock restricted with rules + return .askToJoin + case .restricted(let rules): + let spaceIDs = rules.compactMap { rule in + if case let .roomMembership(id) = rule { + id + } else { + nil + } + } + return .spaceUsers(spaceIDs: spaceIDs) + default: + return .inviteOnly + } + } +} diff --git a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/View/SecurityAndPrivacyScreen.swift b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/View/SecurityAndPrivacyScreen.swift index 63c2b2df6..7d85835cf 100644 --- a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/View/SecurityAndPrivacyScreen.swift +++ b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/View/SecurityAndPrivacyScreen.swift @@ -18,7 +18,7 @@ struct SecurityAndPrivacyScreen: View { roomAccessSection } - if context.desiredSettings.accessType != .inviteOnly, context.viewState.canEditAddress { + if context.desiredSettings.accessType.isAddressRequired, context.viewState.canEditAddress { visibilitySection if let canonicalAlias = context.viewState.canonicalAlias { addressSection(canonicalAlias: canonicalAlias) @@ -57,13 +57,14 @@ struct SecurityAndPrivacyScreen: View { .disabled(!context.viewState.isKnockingEnabled) } - if context.viewState.currentSettings.accessType == .spaceUsers { + if context.viewState.isSpaceMembersOptionAvailable { ListRow(label: .default(title: L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionTitle, - description: L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionDescription, + description: context.viewState.spaceMembersDescription, icon: \.space), - kind: .selection(isSelected: context.desiredSettings.accessType == .spaceUsers) { }) - // This is not supported so it will always be disabled and has no handler - .disabled(true) + kind: .selection(isSelected: context.desiredSettings.accessType.isSpaceUsers) { + context.send(viewAction: .selectedSpaceMembersAccess) + }) + .disabled(!context.viewState.isSpaceMembersOptionSelectable) } ListRow(label: .default(title: L10n.screenSecurityAndPrivacyRoomAccessInviteOnlyOptionTitle, @@ -73,6 +74,15 @@ struct SecurityAndPrivacyScreen: View { } header: { Text(L10n.screenSecurityAndPrivacyRoomAccessSectionHeader) .compoundListSectionHeader() + } footer: { + if let footer = context.viewState.accessSectionFooter { + Text(footer) + .compoundListSectionFooter() + .environment(\.openURL, OpenURLAction { _ in + context.send(viewAction: .manageSpaces) + return .handled + }) + } } } @@ -218,14 +228,36 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview { appSettings: AppSettings()) }() - static let restrictedViewModel = { + static let singleSpaceMembersViewModel = { AppSettings.resetAllSettings() + let appSettings = AppSettings() + appSettings.spaceSettingsEnabled = true + let space = [SpaceRoomProxyProtocol].mockSingleRoom[0] + return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false, canonicalAlias: "#room:matrix.org", members: .allMembersAsCreator, - joinRule: .restricted(rules: []), + joinRule: .restricted(rules: [.roomMembership(roomId: space.id)]), isVisibleInPublicDirectory: true)), - clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org")), + clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org", + spaceServiceConfiguration: .init(joinedParentSpaces: [space]))), + userIndicatorController: UserIndicatorControllerMock(), + appSettings: AppSettings()) + }() + + static let multipleSpacesMembersViewModel = { + AppSettings.resetAllSettings() + let appSettings = AppSettings() + appSettings.spaceSettingsEnabled = true + let spaces = [SpaceRoomProxyProtocol].mockJoinedSpaces + + return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false, + canonicalAlias: "#room:matrix.org", + members: .allMembersAsCreator, + joinRule: .restricted(rules: spaces.map { .roomMembership(roomId: $0.id) }), + isVisibleInPublicDirectory: true)), + clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org", + spaceServiceConfiguration: .init(joinedParentSpaces: spaces))), userIndicatorController: UserIndicatorControllerMock(), appSettings: AppSettings()) }() @@ -278,17 +310,25 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview { .previewDisplayName("Public room without address") NavigationStack { - SecurityAndPrivacyScreen(context: restrictedViewModel.context) + SecurityAndPrivacyScreen(context: singleSpaceMembersViewModel.context) } - .snapshotPreferences(expect: restrictedViewModel.context.$viewState.map { state in + .snapshotPreferences(expect: singleSpaceMembersViewModel.context.$viewState.map { state in state.currentSettings.isVisibileInRoomDirectory == true }) - .previewDisplayName("Restricted room") + .previewDisplayName("Space members") + + NavigationStack { + SecurityAndPrivacyScreen(context: multipleSpacesMembersViewModel.context) + } + .snapshotPreferences(expect: multipleSpacesMembersViewModel.context.$viewState.map { state in + state.currentSettings.isVisibileInRoomDirectory == true + }) + .previewDisplayName("Multiple Spaces members") NavigationStack { SecurityAndPrivacyScreen(context: askToJoinViewModel.context) } - .snapshotPreferences(expect: restrictedViewModel.context.$viewState.map { state in + .snapshotPreferences(expect: askToJoinViewModel.context.$viewState.map { state in state.currentSettings.isVisibileInRoomDirectory == true }) .previewDisplayName("Ask to join room") @@ -296,7 +336,7 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview { NavigationStack { SecurityAndPrivacyScreen(context: publicSpaceViewModel.context) } - .snapshotPreferences(expect: restrictedViewModel.context.$viewState.map { state in + .snapshotPreferences(expect: publicSpaceViewModel.context.$viewState.map { state in state.currentSettings.isVisibileInRoomDirectory == true }) .previewDisplayName("Public space") diff --git a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift index 7073ad5b2..585d33cb3 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxy.swift @@ -49,6 +49,15 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol { } } + func joinedParents(roomID: String) async -> Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError> { + do { + return try await .success(spaceService.joinedParentsOfChild(childId: roomID).map(SpaceRoomProxy.init)) + } catch { + MXLog.error("Failed to get joined parents for \(roomID): \(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 9a30730f9..7bca600ce 100644 --- a/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Spaces/SpaceServiceProxyProtocol.swift @@ -19,4 +19,6 @@ protocol SpaceServiceProxyProtocol { func spaceRoomList(spaceID: String) async -> Result func leaveSpace(spaceID: String) async -> Result + /// Returns all the parent spaces of a room that user has joined. + func joinedParents(roomID: String) async -> Result<[SpaceRoomProxyProtocol], SpaceServiceProxyError> }