implemented the ask to join restricted rule join UI, and added tests and previews for it.

This commit is contained in:
Mauro Romito
2025-12-03 17:16:10 +01:00
committed by Mauro
parent fe6b74d59a
commit d9d9606765
15 changed files with 296 additions and 42 deletions
+14
View File
@@ -508,6 +508,8 @@ internal enum L10n {
internal static var commonLineCopiedToClipboard: String { return L10n.tr("Localizable", "common_line_copied_to_clipboard") }
/// Link copied to clipboard
internal static var commonLinkCopiedToClipboard: String { return L10n.tr("Localizable", "common_link_copied_to_clipboard") }
/// Link new device
internal static var commonLinkNewDevice: String { return L10n.tr("Localizable", "common_link_new_device") }
/// Loading…
internal static var commonLoading: String { return L10n.tr("Localizable", "common_loading") }
/// Loading more…
@@ -1882,6 +1884,12 @@ internal enum L10n {
internal static func screenLeaveSpaceTitleLastAdmin(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_leave_space_title_last_admin", String(describing: p1))
}
/// Desktop computer
internal static var screenLinkNewDeviceDesktopComputer: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_computer") }
/// Mobile device
internal static var screenLinkNewDeviceMobileDevice: String { return L10n.tr("Localizable", "screen_link_new_device_mobile_device") }
/// What type of device do you want to link?
internal static var screenLinkNewDeviceTitle: String { return L10n.tr("Localizable", "screen_link_new_device_title") }
/// This account has been deactivated.
internal static var screenLoginErrorDeactivatedAccount: String { return L10n.tr("Localizable", "screen_login_error_deactivated_account") }
/// Incorrect username and/or password
@@ -2770,10 +2778,16 @@ internal enum L10n {
internal static var screenRoomlistTombstonedRoomDescription: String { return L10n.tr("Localizable", "screen_roomlist_tombstoned_room_description") }
/// Add address
internal static var screenSecurityAndPrivacyAddRoomAddressAction: String { return L10n.tr("Localizable", "screen_security_and_privacy_add_room_address_action") }
/// Anyone in authorized spaces can join, but everyone else must request access.
internal static var screenSecurityAndPrivacyAskToJoinMultipleSpacesMembersOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description") }
/// Everyone must request access.
internal static var screenSecurityAndPrivacyAskToJoinOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_option_description") }
/// Ask to join
internal static var screenSecurityAndPrivacyAskToJoinOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_option_title") }
/// Anyone in %1$@ can join, but everyone else must request access.
internal static func screenSecurityAndPrivacyAskToJoinSingleSpaceMembersOptionDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_single_space_members_option_description", String(describing: p1))
}
/// Yes, enable encryption
internal static var screenSecurityAndPrivacyEnableEncryptionAlertConfirmButtonTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_enable_encryption_alert_confirm_button_title") }
/// Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.
@@ -74,6 +74,14 @@ struct SecurityAndPrivacyScreenViewState: BindableState {
isSpaceSettingsEnabled && selectableSpacesCount > 0
}
var isAskToJoinWithSpaceMembersOptionAvailable: Bool {
currentSettings.accessType.isAskToJoinWithSpaceUsers || isAskToJoinWithSpaceMembersOptionSelectable
}
var isAskToJoinWithSpaceMembersOptionSelectable: Bool {
isSpaceMembersOptionSelectable && isKnockingEnabled
}
var spaceMembersDescription: String {
if isSpaceMembersOptionSelectable {
switch spaceSelection {
@@ -89,10 +97,27 @@ struct SecurityAndPrivacyScreenViewState: BindableState {
}
}
var askToJoinWithSpaceMembersDescription: String {
if isAskToJoinWithSpaceMembersOptionSelectable {
switch spaceSelection {
case .singleJoined(let joinedParentSpace):
L10n.screenSecurityAndPrivacyAskToJoinSingleSpaceMembersOptionDescription(joinedParentSpace.name)
case .singleUnknown(let id):
L10n.screenSecurityAndPrivacyAskToJoinSingleSpaceMembersOptionDescription(id)
case .multiple, .empty:
L10n.screenSecurityAndPrivacyAskToJoinMultipleSpacesMembersOptionDescription
}
} else {
L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionUnavailableDescription
}
}
var accessSectionFooter: AttributedString? {
if bindings.desiredSettings.accessType.isSpaceUsers,
isSpaceMembersOptionSelectable,
case .multiple = spaceSelection {
if (bindings.desiredSettings.accessType.isSpaceUsers &&
isSpaceMembersOptionSelectable) ||
(bindings.desiredSettings.accessType.isAskToJoinWithSpaceUsers &&
isAskToJoinWithSpaceMembersOptionSelectable),
case .multiple = spaceSelection {
Self.accessSectionFooterAttributedString
} else {
nil
@@ -116,8 +141,8 @@ struct SecurityAndPrivacyScreenViewState: BindableState {
} else if selectableSpacesCount > 1 {
.multiple
} else if let joinedParent = joinedParentSpaces.first {
if case let .spaceUsers(ids) = currentSettings.accessType {
if ids.isEmpty {
if currentSettings.accessType.isSpaceUsers || currentSettings.accessType.isAskToJoinWithSpaceUsers {
if currentSettings.accessType.spaceIDs.isEmpty {
// Edge case where the access type is already space members, but it does not contain any id
// So if the user wants to add their own parent they need to do it from the selection menu
.multiple
@@ -185,6 +210,15 @@ enum SecurityAndPrivacyRoomAccessType: Equatable {
}
}
var isAskToJoinWithSpaceUsers: Bool {
switch self {
case .askToJoinWithSpaceUsers:
true
default:
false
}
}
var isAddressRequired: Bool {
switch self {
case .inviteOnly, .spaceUsers:
@@ -215,6 +249,7 @@ enum SecurityAndPrivacyScreenViewAction {
case tryUpdatingEncryption(Bool)
case editAddress
case selectedSpaceMembersAccess
case selectedAskToJoinWithSpaceMembersAccess
case manageSpaces
}
@@ -81,7 +81,9 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
case .selectedSpaceMembersAccess:
handleSelectedSpaceMembersAccess()
case .manageSpaces:
displayManageAuthorizedSpacesScreen()
displayManageAuthorizedSpacesScreen(isAskToJoin: state.bindings.desiredSettings.accessType.isAskToJoinWithSpaceUsers)
case .selectedAskToJoinWithSpaceMembersAccess:
handleSelectedAskToJoinWithSpaceMembersAccess()
}
}
@@ -248,11 +250,29 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
case .empty:
break // Very edge case. We do nothing in this case.
case .multiple:
displayManageAuthorizedSpacesScreen()
displayManageAuthorizedSpacesScreen(isAskToJoin: false)
}
}
private func displayManageAuthorizedSpacesScreen() {
private func handleSelectedAskToJoinWithSpaceMembersAccess() {
guard !state.bindings.desiredSettings.accessType.isAskToJoinWithSpaceUsers else {
// If the user is tapping the ask to join with space members access again we do nothing
return
}
switch state.spaceSelection {
case .singleJoined(let joinedParent):
state.bindings.desiredSettings.accessType = .askToJoinWithSpaceUsers(spaceIDs: [joinedParent.id])
case .singleUnknown(let id):
state.bindings.desiredSettings.accessType = .askToJoinWithSpaceUsers(spaceIDs: [id])
case .empty:
break // Very edge case. We do nothing in this case.
case .multiple:
displayManageAuthorizedSpacesScreen(isAskToJoin: true)
}
}
private func displayManageAuthorizedSpacesScreen(isAskToJoin: Bool) {
let joinedParentSpaces = state.joinedParentSpaces
let unknownSpaceIDs = state.currentSettings.accessType.spaceIDs.filter { id in
!joinedParentSpaces.contains { $0.id == id }
@@ -263,7 +283,8 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
initialSelectedIDs: selectedIDs)
authorizedSpacesSelection.selectedIDs
.sink { [weak self] desiredSelectedIDs in
self?.state.bindings.desiredSettings.accessType = .spaceUsers(spaceIDs: desiredSelectedIDs.sorted())
let sortedIDs = desiredSelectedIDs.sorted()
self?.state.bindings.desiredSettings.accessType = isAskToJoin ? .askToJoinWithSpaceUsers(spaceIDs: sortedIDs) : .spaceUsers(spaceIDs: sortedIDs)
}
.store(in: &cancellables)
@@ -334,20 +355,24 @@ private extension Optional where Wrapped == JoinRule {
return .anyone
case .invite:
return .inviteOnly
case .knock, .knockRestricted:
// TODO: Handle knock restricted with rules
case .knock:
return .askToJoin
case .knockRestricted(let rules):
return .askToJoinWithSpaceUsers(spaceIDs: Self.spaceIDs(from: rules))
case .restricted(let rules):
let spaceIDs = rules.compactMap { rule in
if case let .roomMembership(id) = rule {
id
} else {
nil
}
}
return .spaceUsers(spaceIDs: spaceIDs)
return .spaceUsers(spaceIDs: Self.spaceIDs(from: rules))
default:
return .inviteOnly
}
}
private static func spaceIDs(from rules: [AllowRule]) -> [String] {
rules.compactMap { rule in
if case let .roomMembership(id) = rule {
id
} else {
nil
}
}
}
}
@@ -50,13 +50,6 @@ struct SecurityAndPrivacyScreen: View {
description: L10n.screenSecurityAndPrivacyRoomAccessAnyoneOptionDescription,
icon: \.public),
kind: .selection(isSelected: context.desiredSettings.accessType == .anyone) { context.desiredSettings.accessType = .anyone })
if context.viewState.isKnockingEnabled || context.viewState.currentSettings.accessType == .askToJoin {
ListRow(label: .default(title: L10n.screenSecurityAndPrivacyAskToJoinOptionTitle,
description: L10n.screenSecurityAndPrivacyAskToJoinOptionDescription,
icon: \.userAdd),
kind: .selection(isSelected: context.desiredSettings.accessType == .askToJoin) { context.desiredSettings.accessType = .askToJoin })
.disabled(!context.viewState.isKnockingEnabled)
}
if context.viewState.isSpaceMembersOptionAvailable {
ListRow(label: .default(title: L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionTitle,
@@ -68,6 +61,17 @@ struct SecurityAndPrivacyScreen: View {
.disabled(!context.viewState.isSpaceMembersOptionSelectable)
}
if context.viewState.currentSettings.accessType == .askToJoin {
askToJoinOption
}
// Ask to join and askToJoin with space members can coexist if the initial setting is ask to join but the space members option is available otherwise it's either one or the other.
if context.viewState.isAskToJoinWithSpaceMembersOptionAvailable {
askToJoinWithSpaceMembersOption
} else if context.viewState.isKnockingEnabled, context.viewState.currentSettings.accessType != .askToJoin {
askToJoinOption
}
ListRow(label: .default(title: L10n.screenSecurityAndPrivacyRoomAccessInviteOnlyOptionTitle,
description: L10n.screenSecurityAndPrivacyRoomAccessInviteOnlyOptionDescription,
icon: \.lock),
@@ -87,6 +91,22 @@ struct SecurityAndPrivacyScreen: View {
}
}
private var askToJoinOption: some View {
ListRow(label: .default(title: L10n.screenSecurityAndPrivacyAskToJoinOptionTitle,
description: L10n.screenSecurityAndPrivacyAskToJoinOptionDescription,
icon: \.userAdd),
kind: .selection(isSelected: context.desiredSettings.accessType == .askToJoin) { context.desiredSettings.accessType = .askToJoin })
.disabled(!context.viewState.isKnockingEnabled)
}
private var askToJoinWithSpaceMembersOption: some View {
ListRow(label: .default(title: L10n.screenSecurityAndPrivacyAskToJoinOptionTitle,
description: context.viewState.askToJoinWithSpaceMembersDescription,
icon: \.userAdd),
kind: .selection(isSelected: context.desiredSettings.accessType.isAskToJoinWithSpaceUsers) { context.send(viewAction: .selectedAskToJoinWithSpaceMembersAccess) })
.disabled(!context.viewState.isAskToJoinWithSpaceMembersOptionSelectable)
}
@ViewBuilder
private var encryptionSection: some View {
let binding = Binding<Bool>(get: {
@@ -286,6 +306,42 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
appSettings: appSettings)
}()
static let singleAskToJoinSpaceMembersViewModel = {
AppSettings.resetAllSettings()
let appSettings = AppSettings()
appSettings.spaceSettingsEnabled = true
appSettings.knockingEnabled = true
let space = [SpaceRoomProxyProtocol].mockSingleRoom[0]
return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org",
members: .allMembersAsCreator,
joinRule: .knockRestricted(rules: [.roomMembership(roomId: space.id)]),
isVisibleInPublicDirectory: true)),
clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org",
spaceServiceConfiguration: .init(joinedParentSpaces: [space]))),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: appSettings)
}()
static let multipleAskToJoinSpacesMembersViewModel = {
AppSettings.resetAllSettings()
let appSettings = AppSettings()
appSettings.spaceSettingsEnabled = true
appSettings.knockingEnabled = true
let spaces = [SpaceRoomProxyProtocol].mockJoinedSpaces
return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org",
members: .allMembersAsCreator,
joinRule: .knockRestricted(rules: spaces.map { .roomMembership(roomId: $0.id) }),
isVisibleInPublicDirectory: true)),
clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org",
spaceServiceConfiguration: .init(joinedParentSpaces: spaces))),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: appSettings)
}()
static let publicSpaceViewModel = {
AppSettings.resetAllSettings()
return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isSpace: true,
@@ -342,6 +398,22 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
})
.previewDisplayName("Ask to join room")
NavigationStack {
SecurityAndPrivacyScreen(context: singleAskToJoinSpaceMembersViewModel.context)
}
.snapshotPreferences(expect: singleAskToJoinSpaceMembersViewModel.context.$viewState.map { state in
state.currentSettings.isVisibileInRoomDirectory == true
})
.previewDisplayName("Ask to join with single space members room")
NavigationStack {
SecurityAndPrivacyScreen(context: multipleAskToJoinSpacesMembersViewModel.context)
}
.snapshotPreferences(expect: multipleAskToJoinSpacesMembersViewModel.context.$viewState.map { state in
state.currentSettings.isVisibileInRoomDirectory == true
})
.previewDisplayName("Ask to join with multiple spaces members room")
NavigationStack {
SecurityAndPrivacyScreen(context: publicSpaceViewModel.context)
}