Add alias to public room creation (#3450)

* added the address section

* updated code and strings

* syncing name and address

* improved code

* added a way to reset the state

* better documentation

* update strings

* handling the alias

* alias error state

* update strings

* error handling

* improved the error handling

* new preview tests, even if they do not work well

* improved tests

* unit tests

* pr comments and using the correct value

* fix

* pr comments

* to improve safety and control of the FF

* fixed a test

* updated tests

* update SDK
This commit is contained in:
Mauro
2024-11-08 15:49:20 +01:00
committed by GitHub
parent c567901ec5
commit e7d4ea0d35
39 changed files with 592 additions and 115 deletions
+14 -10
View File
@@ -1093,16 +1093,6 @@ internal enum L10n {
internal static var screenCreatePollQuestionHint: String { return L10n.tr("Localizable", "screen_create_poll_question_hint") }
/// Create Poll
internal static var screenCreatePollTitle: String { return L10n.tr("Localizable", "screen_create_poll_title") }
/// Anyone can join this room
internal static var screenCreateRoomAccessSectionAnyoneOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_access_section_anyone_option_description") }
/// Anyone
internal static var screenCreateRoomAccessSectionAnyoneOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_access_section_anyone_option_title") }
/// Room Access
internal static var screenCreateRoomAccessSectionHeader: String { return L10n.tr("Localizable", "screen_create_room_access_section_header") }
/// Anyone can ask to join the room but an administrator or a moderator will have to accept the request
internal static var screenCreateRoomAccessSectionKnockingOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_access_section_knocking_option_description") }
/// Ask to join
internal static var screenCreateRoomAccessSectionKnockingOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_access_section_knocking_option_title") }
/// New room
internal static var screenCreateRoomActionCreateRoom: String { return L10n.tr("Localizable", "screen_create_room_action_create_room") }
/// Invite people
@@ -1118,6 +1108,20 @@ internal enum L10n {
internal static var screenCreateRoomPublicOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_public_option_description") }
/// Public room
internal static var screenCreateRoomPublicOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_public_option_title") }
/// Anyone can join this room
internal static var screenCreateRoomRoomAccessSectionAnyoneOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_anyone_option_description") }
/// Anyone
internal static var screenCreateRoomRoomAccessSectionAnyoneOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_anyone_option_title") }
/// Room Access
internal static var screenCreateRoomRoomAccessSectionHeader: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_header") }
/// Anyone can ask to join the room but an administrator or a moderator will have to accept the request
internal static var screenCreateRoomRoomAccessSectionKnockingOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_option_description") }
/// Ask to join
internal static var screenCreateRoomRoomAccessSectionKnockingOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_access_section_knocking_option_title") }
/// Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _
internal static var screenCreateRoomRoomAddressInvalidSymbolsErrorDescription: String { return L10n.tr("Localizable", "screen_create_room_room_address_invalid_symbols_error_description") }
/// This room address already exists, please try editing the room address field or change the room name
internal static var screenCreateRoomRoomAddressNotAvailableErrorDescription: String { return L10n.tr("Localizable", "screen_create_room_room_address_not_available_error_description") }
/// In order for this room to be visible in the public room directory, you will need a room address.
internal static var screenCreateRoomRoomAddressSectionFooter: String { return L10n.tr("Localizable", "screen_create_room_room_address_section_footer") }
/// Room address
+6 -2
View File
@@ -9,6 +9,8 @@ import Combine
import Foundation
struct ClientProxyMockConfiguration {
var homeserver = ""
var userIDServerName: String?
var userID: String = RoomMemberProxyMock.mockMe.userID
var deviceID: String?
var roomSummaryProvider: RoomSummaryProviderProtocol? = RoomSummaryProviderMock(.init())
@@ -28,7 +30,8 @@ extension ClientProxyMock {
userID = configuration.userID
deviceID = configuration.deviceID
homeserver = ""
homeserver = configuration.homeserver
userIDServerName = configuration.userIDServerName
roomSummaryProvider = configuration.roomSummaryProvider
alternateRoomSummaryProvider = RoomSummaryProviderMock(.init())
@@ -52,13 +55,14 @@ extension ClientProxyMock {
canDeactivateAccount = false
directRoomForUserIDReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
createDirectRoomWithExpectedRoomNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
uploadMediaReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
loadUserDisplayNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
setUserDisplayNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
loadUserAvatarURLReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
setUserAvatarMediaReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
removeUserAvatarReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
isAliasAvailableReturnValue = .success(true)
logoutReturnValue = nil
searchUsersSearchTermLimitReturnValue = .success(.init(results: [], limited: false))
profileForReturnValue = .success(.init(userID: "@a:b.com", displayName: "Some user"))
@@ -2628,15 +2628,15 @@ class ClientProxyMock: ClientProxyProtocol {
}
//MARK: - createRoom
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingCallsCount = 0
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLCallsCount: Int {
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingCallsCount = 0
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartCallsCount: Int {
get {
if Thread.isMainThread {
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingCallsCount
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingCallsCount
returnValue = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingCallsCount
}
return returnValue!
@@ -2644,29 +2644,29 @@ class ClientProxyMock: ClientProxyProtocol {
}
set {
if Thread.isMainThread {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingCallsCount = newValue
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingCallsCount = newValue
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingCallsCount = newValue
}
}
}
}
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLCalled: Bool {
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLCallsCount > 0
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartCalled: Bool {
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartCallsCount > 0
}
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReceivedArguments: (name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?)?
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReceivedInvocations: [(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?)] = []
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReceivedArguments: (name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)?
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReceivedInvocations: [(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?)] = []
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingReturnValue: Result<String, ClientProxyError>!
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReturnValue: Result<String, ClientProxyError>! {
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue: Result<String, ClientProxyError>!
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReturnValue: Result<String, ClientProxyError>! {
get {
if Thread.isMainThread {
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingReturnValue
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue
} else {
var returnValue: Result<String, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingReturnValue
returnValue = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue
}
return returnValue!
@@ -2674,26 +2674,26 @@ class ClientProxyMock: ClientProxyProtocol {
}
set {
if Thread.isMainThread {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingReturnValue = newValue
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLUnderlyingReturnValue = newValue
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartUnderlyingReturnValue = newValue
}
}
}
}
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLClosure: ((String, String?, Bool, Bool, [String], URL?) async -> Result<String, ClientProxyError>)?
var createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartClosure: ((String, String?, Bool, Bool, [String], URL?, String?) async -> Result<String, ClientProxyError>)?
func createRoom(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError> {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLCallsCount += 1
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReceivedArguments = (name: name, topic: topic, isRoomPrivate: isRoomPrivate, isKnockingOnly: isKnockingOnly, userIDs: userIDs, avatarURL: avatarURL)
func createRoom(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?, aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartCallsCount += 1
createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReceivedArguments = (name: name, topic: topic, isRoomPrivate: isRoomPrivate, isKnockingOnly: isKnockingOnly, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart)
DispatchQueue.main.async {
self.createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReceivedInvocations.append((name: name, topic: topic, isRoomPrivate: isRoomPrivate, isKnockingOnly: isKnockingOnly, userIDs: userIDs, avatarURL: avatarURL))
self.createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReceivedInvocations.append((name: name, topic: topic, isRoomPrivate: isRoomPrivate, isKnockingOnly: isKnockingOnly, userIDs: userIDs, avatarURL: avatarURL, aliasLocalPart: aliasLocalPart))
}
if let createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLClosure = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLClosure {
return await createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLClosure(name, topic, isRoomPrivate, isKnockingOnly, userIDs, avatarURL)
if let createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartClosure = createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartClosure {
return await createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartClosure(name, topic, isRoomPrivate, isKnockingOnly, userIDs, avatarURL, aliasLocalPart)
} else {
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLReturnValue
return createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReturnValue
}
}
//MARK: - joinRoom
@@ -3973,6 +3973,76 @@ class ClientProxyMock: ClientProxyProtocol {
return resolveRoomAliasReturnValue
}
}
//MARK: - isAliasAvailable
var isAliasAvailableUnderlyingCallsCount = 0
var isAliasAvailableCallsCount: Int {
get {
if Thread.isMainThread {
return isAliasAvailableUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = isAliasAvailableUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
isAliasAvailableUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
isAliasAvailableUnderlyingCallsCount = newValue
}
}
}
}
var isAliasAvailableCalled: Bool {
return isAliasAvailableCallsCount > 0
}
var isAliasAvailableReceivedAlias: String?
var isAliasAvailableReceivedInvocations: [String] = []
var isAliasAvailableUnderlyingReturnValue: Result<Bool, ClientProxyError>!
var isAliasAvailableReturnValue: Result<Bool, ClientProxyError>! {
get {
if Thread.isMainThread {
return isAliasAvailableUnderlyingReturnValue
} else {
var returnValue: Result<Bool, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = isAliasAvailableUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
isAliasAvailableUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
isAliasAvailableUnderlyingReturnValue = newValue
}
}
}
}
var isAliasAvailableClosure: ((String) async -> Result<Bool, ClientProxyError>)?
func isAliasAvailable(_ alias: String) async -> Result<Bool, ClientProxyError> {
isAliasAvailableCallsCount += 1
isAliasAvailableReceivedAlias = alias
DispatchQueue.main.async {
self.isAliasAvailableReceivedInvocations.append(alias)
}
if let isAliasAvailableClosure = isAliasAvailableClosure {
return await isAliasAvailableClosure(alias)
} else {
return isAliasAvailableReturnValue
}
}
//MARK: - getElementWellKnown
var getElementWellKnownUnderlyingCallsCount = 0
@@ -25,20 +25,32 @@ enum CreateRoomViewModelAction {
}
struct CreateRoomViewState: BindableState {
var roomName: String
let serverName: String
let isKnockingFeatureEnabled: Bool
var selectedUsers: [UserProfileProxy]
var aliasLocalPart: String
var bindings: CreateRoomViewStateBindings
var avatarURL: URL?
var canCreateRoom: Bool {
!bindings.roomName.isEmpty
!roomName.isEmpty && aliasErrors.isEmpty
}
var aliasErrors: Set<CreateRoomAliasErrorState> = []
var aliasErrorDescription: String? {
if aliasErrors.contains(.alreadyExists) {
return L10n.screenCreateRoomRoomAddressNotAvailableErrorDescription
} else if aliasErrors.contains(.invalidSymbols) {
return L10n.screenCreateRoomRoomAddressInvalidSymbolsErrorDescription
}
return nil
}
}
struct CreateRoomViewStateBindings {
var roomName: String
var roomTopic: String
var isRoomPrivate: Bool
var isKnockingOnly = false
var isKnockingOnly: Bool
var showAttachmentConfirmationDialog = false
/// Information describing the currently displayed alert.
@@ -51,4 +63,11 @@ enum CreateRoomViewAction {
case displayCameraPicker
case displayMediaPicker
case removeImage
case updateRoomName(String)
case updateAliasLocalPart(String)
}
enum CreateRoomAliasErrorState {
case alreadyExists
case invalidSymbols
}
@@ -6,6 +6,7 @@
//
import Combine
import MatrixRustSDK
import SwiftUI
typealias CreateRoomViewModelType = StateStoreViewModel<CreateRoomViewState, CreateRoomViewAction>
@@ -15,6 +16,8 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
private var createRoomParameters: CreateRoomFlowParameters
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private var syncNameAndAlias = true
@CancellableTask private var checkAliasAvailabilityTask: Task<Void, Never>?
private var actionsSubject: PassthroughSubject<CreateRoomViewModelAction, Never> = .init()
@@ -35,9 +38,17 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
self.analytics = analytics
self.userIndicatorController = userIndicatorController
let bindings = CreateRoomViewStateBindings(roomName: parameters.name, roomTopic: parameters.topic, isRoomPrivate: parameters.isRoomPrivate)
let bindings = CreateRoomViewStateBindings(roomTopic: parameters.topic,
isRoomPrivate: parameters.isRoomPrivate,
isKnockingOnly: appSettings.knockingEnabled ? parameters.isKnockingOnly : false)
super.init(initialViewState: CreateRoomViewState(isKnockingFeatureEnabled: appSettings.knockingEnabled, selectedUsers: selectedUsers.value, bindings: bindings), mediaProvider: userSession.mediaProvider)
super.init(initialViewState: CreateRoomViewState(roomName: parameters.name,
serverName: userSession.clientProxy.userIDServerName ?? "",
isKnockingFeatureEnabled: appSettings.knockingEnabled,
selectedUsers: selectedUsers.value,
aliasLocalPart: parameters.aliasLocalPart ?? roomAliasNameFromRoomDisplayName(roomName: parameters.name),
bindings: bindings),
mediaProvider: userSession.mediaProvider)
createRoomParameters
.map(\.avatarImageMedia)
@@ -80,31 +91,111 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
actionsSubject.send(.displayMediaPicker)
case .removeImage:
actionsSubject.send(.removeImage)
case .updateAliasLocalPart(let aliasLocalPart):
state.aliasLocalPart = aliasLocalPart
// If this has been called this means that the user wants a custom address not necessarily reflecting the name
// So we disable the two from syncing.
syncNameAndAlias = false
case .updateRoomName(let name):
// Reset the syncing if the name is fully cancelled
if name.isEmpty {
syncNameAndAlias = true
}
state.roomName = name
if syncNameAndAlias {
state.aliasLocalPart = roomAliasNameFromRoomDisplayName(roomName: name)
}
}
}
// MARK: - Private
private func setupBindings() {
// Reset the state related to public rooms if the user choses the room to be empty
context.$viewState
.dropFirst()
.map(\.bindings.isRoomPrivate)
.removeDuplicates()
.filter { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
state.bindings.isKnockingOnly = false
state.aliasErrors = []
state.aliasLocalPart = roomAliasNameFromRoomDisplayName(roomName: state.roomName)
syncNameAndAlias = true
}
.store(in: &cancellables)
context.$viewState
.map(\.bindings)
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.removeDuplicates { old, new in
old.roomName == new.roomName && old.roomTopic == new.roomTopic && old.isRoomPrivate == new.isRoomPrivate
old.roomName == new.roomName &&
old.bindings.roomTopic == new.bindings.roomTopic &&
old.bindings.isRoomPrivate == new.bindings.isRoomPrivate &&
old.bindings.isKnockingOnly == new.bindings.isKnockingOnly &&
old.aliasLocalPart == new.aliasLocalPart
}
.sink { [weak self] bindings in
.sink { [weak self] state in
guard let self else { return }
updateParameters(bindings: bindings)
updateParameters(state: state)
actionsSubject.send(.updateDetails(createRoomParameters))
}
.store(in: &cancellables)
context.$viewState
.map(\.aliasLocalPart)
.removeDuplicates()
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { [weak self] aliasLocalPart in
guard let self else {
return
}
guard state.isKnockingFeatureEnabled,
!state.bindings.isRoomPrivate,
let canonicalAlias = canonicalAlias(aliasLocalPart: aliasLocalPart) else {
// While is empty or private room we don't change or display the error
return
}
if !isRoomAliasFormatValid(alias: canonicalAlias) {
state.aliasErrors.insert(.invalidSymbols)
// If the alias is invalid we don't need to check for availability
state.aliasErrors.remove(.alreadyExists)
checkAliasAvailabilityTask = nil
return
}
state.aliasErrors.remove(.invalidSymbols)
checkAliasAvailabilityTask = Task { [weak self] in
guard let self else {
return
}
if case .success(false) = await self.userSession.clientProxy.isAliasAvailable(canonicalAlias) {
guard !Task.isCancelled else { return }
state.aliasErrors.insert(.alreadyExists)
} else {
guard !Task.isCancelled else { return }
state.aliasErrors.remove(.alreadyExists)
}
}
}
.store(in: &cancellables)
}
private func updateParameters(bindings: CreateRoomViewStateBindings) {
createRoomParameters.name = bindings.roomName
createRoomParameters.topic = bindings.roomTopic
createRoomParameters.isRoomPrivate = bindings.isRoomPrivate
createRoomParameters.isKnockingOnly = bindings.isKnockingOnly
private func updateParameters(state: CreateRoomViewState) {
createRoomParameters.name = state.roomName
createRoomParameters.topic = state.bindings.roomTopic
createRoomParameters.isRoomPrivate = state.bindings.isRoomPrivate
createRoomParameters.isKnockingOnly = state.bindings.isKnockingOnly
if state.isKnockingFeatureEnabled, !state.aliasLocalPart.isEmpty {
createRoomParameters.aliasLocalPart = state.aliasLocalPart
} else {
createRoomParameters.aliasLocalPart = nil
}
}
private func createRoom() async {
@@ -114,7 +205,28 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
showLoadingIndicator()
// Since the parameters are throttled, we need to make sure that the latest values are used
updateParameters(bindings: state.bindings)
updateParameters(state: state)
// Better to double check the errors also when trying to create the room
if state.isKnockingFeatureEnabled, !createRoomParameters.isRoomPrivate {
guard let canonicalAlias = canonicalAlias(aliasLocalPart: createRoomParameters.aliasLocalPart),
isRoomAliasFormatValid(alias: canonicalAlias) else {
state.aliasErrors = [.invalidSymbols]
return
}
switch await userSession.clientProxy.isAliasAvailable(canonicalAlias) {
case .success(true):
break
case .success(false):
state.aliasErrors = [.alreadyExists]
return
case .failure:
state.bindings.alertInfo = AlertInfo(id: .unknown)
return
}
}
let avatarURL: URL?
if let media = createRoomParameters.avatarImageMedia {
switch await userSession.clientProxy.uploadMedia(media) {
@@ -147,7 +259,8 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
// As of right now we don't want to make private rooms with the knock rule
isKnockingOnly: createRoomParameters.isRoomPrivate ? false : createRoomParameters.isKnockingOnly,
userIDs: state.selectedUsers.map(\.userID),
avatarURL: avatarURL) {
avatarURL: avatarURL,
aliasLocalPart: createRoomParameters.isRoomPrivate ? nil : createRoomParameters.aliasLocalPart) {
case .success(let roomId):
analytics.trackCreatedRoom(isDM: false)
actionsSubject.send(.openRoom(withIdentifier: roomId))
@@ -158,6 +271,14 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
}
}
func canonicalAlias(aliasLocalPart: String?) -> String? {
guard let aliasLocalPart,
!aliasLocalPart.isEmpty else {
return nil
}
return "#\(aliasLocalPart):\(state.serverName)"
}
// MARK: Loading indicator
private static let loadingIndicatorIdentifier = "\(CreateRoomViewModel.self)-Loading"
@@ -16,7 +16,23 @@ struct CreateRoomScreen: View {
case name
case topic
}
private var aliasBinding: Binding<String> {
.init(get: {
context.viewState.aliasLocalPart
}, set: {
context.send(viewAction: .updateAliasLocalPart($0))
})
}
private var roomNameBinding: Binding<String> {
.init(get: {
context.viewState.roomName
}, set: {
context.send(viewAction: .updateRoomName($0))
})
}
var body: some View {
Form {
roomSection
@@ -25,6 +41,7 @@ struct CreateRoomScreen: View {
if context.viewState.isKnockingFeatureEnabled,
!context.isRoomPrivate {
roomAccessSection
roomAliasSection
}
}
.compoundList()
@@ -48,7 +65,7 @@ struct CreateRoomScreen: View {
.compoundListSectionHeader()
TextField(L10n.screenCreateRoomRoomNameLabel,
text: $context.roomName,
text: roomNameBinding,
prompt: Text(L10n.commonRoomNamePlaceholder).foregroundColor(.compound.textPlaceholder),
axis: .horizontal)
.focused($focus, equals: .name)
@@ -150,25 +167,65 @@ struct CreateRoomScreen: View {
iconAlignment: .top),
kind: .selection(isSelected: !context.isRoomPrivate) { context.isRoomPrivate = false })
} header: {
Text(L10n.commonSecurity.uppercased())
Text(L10n.screenCreateRoomRoomVisibilitySectionTitle)
.compoundListSectionHeader()
}
}
private var roomAccessSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenCreateRoomAccessSectionAnyoneOptionTitle,
description: L10n.screenCreateRoomAccessSectionAnyoneOptionDescription),
ListRow(label: .plain(title: L10n.screenCreateRoomRoomAccessSectionAnyoneOptionTitle,
description: L10n.screenCreateRoomRoomAccessSectionAnyoneOptionDescription),
kind: .selection(isSelected: !context.isKnockingOnly) { context.isKnockingOnly = false })
ListRow(label: .plain(title: L10n.screenCreateRoomAccessSectionKnockingOptionTitle,
description: L10n.screenCreateRoomAccessSectionKnockingOptionDescription),
ListRow(label: .plain(title: L10n.screenCreateRoomRoomAccessSectionKnockingOptionTitle,
description: L10n.screenCreateRoomRoomAccessSectionKnockingOptionDescription),
kind: .selection(isSelected: context.isKnockingOnly) { context.isKnockingOnly = true })
} header: {
Text(L10n.screenCreateRoomAccessSectionHeader.uppercased())
Text(L10n.screenCreateRoomRoomAccessSectionHeader)
.compoundListSectionHeader()
}
}
private var roomAliasSection: some View {
Section {
ListRow(kind: .custom {
HStack(spacing: 0) {
Text("#")
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
TextField("", text: aliasBinding)
.autocapitalization(.none)
.textCase(.lowercase)
.tint(.compound.iconAccentTertiary)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.padding(.horizontal, 8)
Text(":\(context.viewState.serverName)")
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
}
.padding(ListRowPadding.textFieldInsets)
.environment(\.layoutDirection, .leftToRight)
.errorBackground(!context.viewState.aliasErrors.isEmpty)
})
} header: {
Text(L10n.screenCreateRoomRoomAddressSectionTitle)
.compoundListSectionHeader()
} footer: {
VStack(alignment: .leading, spacing: 12) {
if let errorDescription = context.viewState.aliasErrorDescription {
Label(errorDescription, icon: \.error, iconSize: .xSmall, relativeTo: .compound.bodySM)
.foregroundStyle(.compound.textCriticalPrimary)
.font(.compound.bodySM)
}
Text(L10n.screenCreateRoomRoomAddressSectionFooter)
.compoundListSectionFooter()
.font(.compound.bodySM)
}
}
}
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionCreate) {
@@ -180,6 +237,15 @@ struct CreateRoomScreen: View {
}
}
private extension View {
func errorBackground(_ shouldDisplay: Bool) -> some View {
listRowBackground(shouldDisplay ? AnyView(RoundedRectangle(cornerRadius: 10)
.inset(by: 1)
.fill(.compound.bgCriticalSubtleHovered)
.stroke(Color.compound.borderCriticalPrimary)) : AnyView(Color.compound.bgCanvasDefaultLevel1))
}
}
// MARK: - Previews
struct CreateRoom_Previews: PreviewProvider, TestablePreview {
@@ -208,7 +274,7 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
}()
static let publicRoomViewModel = {
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
let parameters = CreateRoomFlowParameters(isRoomPrivate: false)
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
ServiceLocator.shared.settings.knockingEnabled = true
@@ -220,6 +286,32 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings)
}()
static let publicRoomInvalidAliasViewModel = {
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
let parameters = CreateRoomFlowParameters(isRoomPrivate: false, aliasLocalPart: "#:")
ServiceLocator.shared.settings.knockingEnabled = true
return CreateRoomViewModel(userSession: userSession,
createRoomParameters: .init(parameters),
selectedUsers: .init([]),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
}()
static let publicRoomExistingAliasViewModel = {
let clientProxy = ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))
clientProxy.isAliasAvailableReturnValue = .success(false)
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let parameters = CreateRoomFlowParameters(isRoomPrivate: false, aliasLocalPart: "existing")
ServiceLocator.shared.settings.knockingEnabled = true
return CreateRoomViewModel(userSession: userSession,
createRoomParameters: .init(parameters),
selectedUsers: .init([]),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
}()
static var previews: some View {
NavigationStack {
CreateRoomScreen(context: viewModel.context)
@@ -233,5 +325,15 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
CreateRoomScreen(context: publicRoomViewModel.context)
}
.previewDisplayName("Create Public Room")
NavigationStack {
CreateRoomScreen(context: publicRoomInvalidAliasViewModel.context)
}
.snapshotPreferences(delay: 1.5)
.previewDisplayName("Create Public Room, invalid alias")
NavigationStack {
CreateRoomScreen(context: publicRoomExistingAliasViewModel.context)
}
.snapshotPreferences(delay: 1.5)
.previewDisplayName("Create Public Room, existing alias")
}
}
@@ -239,13 +239,13 @@ struct SecureBackupRecoveryKeyScreen_Previews: PreviewProvider, TestablePreview
SecureBackupRecoveryKeyScreen(context: generatingViewModel.context)
}
.previewDisplayName("Generating")
.snapshot(delay: 0.25)
.snapshotPreferences(delay: 0.25)
NavigationStack {
SecureBackupRecoveryKeyScreen(context: setupViewModel.context)
}
.previewDisplayName("Set up")
.snapshot(delay: 0.25)
.snapshotPreferences(delay: 0.25)
NavigationStack {
SecureBackupRecoveryKeyScreen(context: incompleteViewModel.context)
@@ -67,6 +67,22 @@ class ClientProxy: ClientProxyProtocol {
"org.matrix.msc3401.call.member": Int32(0)
])
}
private static var knockingRoomCreationPowerLevelOverrides: PowerLevels {
.init(usersDefault: nil,
eventsDefault: nil,
stateDefault: nil,
ban: nil,
kick: nil,
redact: nil,
invite: Int32(50),
notifications: nil,
users: [:],
events: [
"m.call.member": Int32(0),
"org.matrix.msc3401.call.member": Int32(0)
])
}
private var loadCachedAvatarURLTask: Task<Void, Never>?
private let userAvatarURLSubject = CurrentValueSubject<URL?, Never>(nil)
@@ -377,9 +393,14 @@ class ClientProxy: ClientProxyProtocol {
}
// swiftlint:disable:next function_parameter_count
func createRoom(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError> {
func createRoom(name: String,
topic: String?,
isRoomPrivate: Bool,
isKnockingOnly: Bool,
userIDs: [String],
avatarURL: URL?,
aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
do {
// TODO: Revisit once the SDK supports the knocking API
let parameters = CreateRoomParameters(name: name,
topic: topic,
isEncrypted: isRoomPrivate,
@@ -388,7 +409,9 @@ class ClientProxy: ClientProxyProtocol {
preset: isRoomPrivate ? .privateChat : .publicChat,
invite: userIDs,
avatar: avatarURL?.absoluteString,
powerLevelContentOverride: Self.roomCreationPowerLevelOverrides)
powerLevelContentOverride: isKnockingOnly ? Self.knockingRoomCreationPowerLevelOverrides : Self.roomCreationPowerLevelOverrides,
// This is an FFI naming mistake, what is required is the `aliasLocalPart` not the whole alias
canonicalAlias: aliasLocalPart)
let roomID = try await client.createRoom(request: parameters)
await waitForRoomToSync(roomID: roomID)
@@ -626,6 +649,7 @@ class ClientProxy: ClientProxyProtocol {
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError> {
do {
guard let resolvedAlias = try await client.resolveRoomAlias(roomAlias: alias) else {
MXLog.error("Failed resolving room alias, is nil")
return .failure(.failedResolvingRoomAlias)
}
@@ -641,6 +665,16 @@ class ClientProxy: ClientProxyProtocol {
}
}
func isAliasAvailable(_ alias: String) async -> Result<Bool, ClientProxyError> {
do {
let result = try await client.isRoomAliasAvailable(alias: alias)
return .success(result)
} catch {
MXLog.error("Failed checking if alias: \(alias) is available with error: \(error)")
return .failure(.sdkError(error))
}
}
func getElementWellKnown() async -> Result<ElementWellKnown?, ClientProxyError> {
await client.getElementWellKnown().map(ElementWellKnown.init)
}
@@ -88,7 +88,7 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var deviceID: String? { get }
var homeserver: String { get }
var slidingSyncVersion: SlidingSyncVersion { get }
var availableSlidingSyncVersions: [SlidingSyncVersion] { get async }
@@ -133,7 +133,13 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
// swiftlint:disable:next function_parameter_count
func createRoom(name: String, topic: String?, isRoomPrivate: Bool, isKnockingOnly: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError>
func createRoom(name: String,
topic: String?,
isRoomPrivate: Bool,
isKnockingOnly: Bool,
userIDs: [String],
avatarURL: URL?,
aliasLocalPart: String?) async -> Result<String, ClientProxyError>
func joinRoom(_ roomID: String, via: [String]) async -> Result<Void, ClientProxyError>
@@ -173,6 +179,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
func isAliasAvailable(_ alias: String) async -> Result<Bool, ClientProxyError>
func getElementWellKnown() async -> Result<ElementWellKnown?, ClientProxyError>
// MARK: - Ignored users
@@ -14,4 +14,5 @@ struct CreateRoomFlowParameters {
var isRoomPrivate = true
var isKnockingOnly = false
var avatarImageMedia: MediaInfo?
var aliasLocalPart: String?
}