diff --git a/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift b/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift index 17221330f..45ae8654a 100644 --- a/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift +++ b/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift @@ -14,7 +14,7 @@ final class PrivacyViewModel { let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let rows: [PrivacyRow] - let instance: Mastodon.Entity.Instance + let instance: RegistrationInstance let applicationToken: Mastodon.Entity.Token let didAccept: ()->() @@ -22,7 +22,7 @@ final class PrivacyViewModel { domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, rows: [PrivacyRow], - instance: Mastodon.Entity.Instance, + instance: RegistrationInstance, applicationToken: Mastodon.Entity.Token, didAccept: @escaping ()->() ) { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterView.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterView.swift index f941cbbec..a4f473bf8 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterView.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterView.swift @@ -13,19 +13,27 @@ import MastodonAsset struct MastodonRegisterView: View { + @FocusState var focusedField: MastodonRegisterViewModel.RegistrationField? + @ObservedObject var viewModel: MastodonRegisterViewModel @State var usernameRightViewWidth: CGFloat = 300 + @State var dateOfBirthLabel = L10n.Scene.Register.Input.BirthDate.label.localizedCapitalized + var body: some View { ScrollView(.vertical) { let margin: CGFloat = 16 VStack(alignment: .leading, spacing: 16) { Spacer() + if let minAge = viewModel.minAge { + dateOfBirthEntry(minAge: minAge) + } TextField(L10n.Scene.Register.Input.DisplayName.placeholder.localizedCapitalized, text: $viewModel.name) .textContentType(.name) .disableAutocorrection(true) .modifier(FormTextFieldModifier(validateState: viewModel.displayNameValidateState)) + .focused($focusedField, equals: .displayName) HStack { Text("@") .accessibilityHidden(true) @@ -35,6 +43,7 @@ struct MastodonRegisterView: View { .disableAutocorrection(true) .keyboardType(.asciiCapable) .accessibilityLabel(viewModel.accessibilityLabelUsernameField) + .focused($focusedField, equals: .handle) Text("@\(viewModel.domain)") .lineLimit(1) .truncationMode(.middle) @@ -69,6 +78,7 @@ struct MastodonRegisterView: View { .disableAutocorrection(true) .keyboardType(.emailAddress) .modifier(FormTextFieldModifier(validateState: viewModel.emailValidateState)) + .focused($focusedField, equals: .email) if let errorPrompt = viewModel.emailErrorPrompt { Text(errorPrompt) .modifier(FormFootnoteModifier()) @@ -82,10 +92,12 @@ struct MastodonRegisterView: View { VStack(alignment: .leading, spacing: margin) { SecureField(L10n.Scene.Register.Input.Password.placeholder.localizedCapitalized, text: $viewModel.password) .textContentType(.newPassword) - .modifier(FormTextFieldModifier(validateState: viewModel.passwordValidateState)) + .modifier(FormTextFieldModifier(validateState: viewModel.passwordBaseValidateState)) + .focused($focusedField, equals: .password) SecureField(L10n.Scene.Register.Input.Password.confirmationPlaceholder.localizedCapitalized, text: $viewModel.passwordConfirmation) .textContentType(.newPassword) - .modifier(FormTextFieldModifier(validateState: viewModel.passwordValidateState)) + .modifier(FormTextFieldModifier(validateState: viewModel.passwordConfirmationValidateState)) + .focused($focusedField, equals: .confirmPassword) Text(L10n.Scene.Register.Input.Password.hint) .modifier(FormFootnoteModifier(foregroundColor: .secondary)) if let errorPrompt = viewModel.passwordErrorPrompt { @@ -101,6 +113,7 @@ struct MastodonRegisterView: View { VStack(alignment: .leading, spacing: 11) { TextField(L10n.Scene.Register.Input.Invite.registrationUserInviteRequest.localizedCapitalized, text: $viewModel.reason) .modifier(FormTextFieldModifier(validateState: viewModel.reasonValidateState)) + .focused($focusedField, equals: .proposedApprovalReason) if let errorPrompt = viewModel.reasonErrorPrompt { Text(errorPrompt) .modifier(FormFootnoteModifier()) @@ -119,6 +132,9 @@ struct MastodonRegisterView: View { } ) .scrollDismissesKeyboard(.interactively) + .onChange(of: focusedField) { _, newValue in + viewModel.editingField = newValue + } } struct FormTextFieldModifier: ViewModifier { @@ -128,7 +144,7 @@ struct MastodonRegisterView: View { ZStack { let borderColor: Color = { switch validateState { - case .empty: return Color(Asset.Scene.Onboarding.textFieldBackground.color) + case .empty, .filling: return Color(Asset.Scene.Onboarding.textFieldBackground.color) case .invalid: return Color(Asset.Colors.TextField.invalid.color.withAlphaComponent(0.25)) case .valid: return Color(Asset.Scene.Onboarding.textFieldBackground.color) } @@ -153,7 +169,22 @@ struct MastodonRegisterView: View { .foregroundColor(foregroundColor) } } - + + @ViewBuilder func dateOfBirthEntry(minAge: Int) -> some View { + VStack { + ZStack { + TextField(L10n.Scene.Register.Input.BirthDate.label.localizedCapitalized, text: $dateOfBirthLabel) + .disabled(true) + .modifier(FormTextFieldModifier(validateState: viewModel.dateOfBirthValidateState)) + HStack { + Spacer().frame(maxWidth: .infinity) + DatePicker(selection: $viewModel.dateOfBirth, in: ...Date.now, displayedComponents: .date) { } + Spacer() + } + } + Text(L10n.Scene.Register.Input.BirthDate.explanationMessage(minAge, viewModel.domain)).font(.callout) + } + } } struct WidthKey: PreferenceKey { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 1131ba98f..105f0a088 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -12,20 +12,34 @@ import UIKit import MastodonAsset import MastodonCore import MastodonLocalization +import SwiftUI @MainActor final class MastodonRegisterViewModel: ObservableObject { + + enum RegistrationField: Hashable { + case displayName + case handle + case email + case password + case confirmPassword + case dateOfBirth + case proposedApprovalReason + } + + var disposeBag = Set() // input let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo - let instance: Mastodon.Entity.Instance + let instance: RegistrationInstance let applicationToken: Mastodon.Entity.Token let viewDidAppear = CurrentValueSubject(Void()) let submitValidatedUserRegistration: (MastodonRegisterViewModel, Bool) async -> () @Published var backgroundColor: UIColor = Asset.Scene.Onboarding.background.color + @Published var dateOfBirth = Date.now @Published var name = "" @Published var username = "" @Published var email = "" @@ -43,13 +57,24 @@ final class MastodonRegisterViewModel: ObservableObject { // output var diffableDataSource: UITableViewDiffableDataSource? let approvalRequired: Bool + let minAge: Int? let applicationAuthorization: Mastodon.API.OAuth.Authorization + @Published var dateOfBirthValidateState: ValidateState = .empty @Published var usernameValidateState: ValidateState = .empty @Published var displayNameValidateState: ValidateState = .empty @Published var emailValidateState: ValidateState = .empty - @Published var passwordValidateState: ValidateState = .empty + @Published var passwordBaseValidateState: ValidateState = .empty + @Published var passwordConfirmationValidateState: ValidateState = .empty @Published var reasonValidateState: ValidateState = .empty + + public var editingField: RegistrationField? { + didSet { + if let oldValue { + validate(oldValue) + } + } + } @Published var isRegistering = false @Published var isAllValid = false @@ -60,7 +85,7 @@ final class MastodonRegisterViewModel: ObservableObject { init( domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, - instance: Mastodon.Entity.Instance, + instance: RegistrationInstance, applicationToken: Mastodon.Entity.Token, submitValidatedUserRegistration: @escaping (MastodonRegisterViewModel, Bool) async ->() ) { @@ -69,36 +94,66 @@ final class MastodonRegisterViewModel: ObservableObject { self.instance = instance self.applicationToken = applicationToken self.approvalRequired = instance.approvalRequired ?? false + self.minAge = instance.minAge self.applicationAuthorization = Mastodon.API.OAuth.Authorization(accessToken: applicationToken.accessToken) self.submitValidatedUserRegistration = submitValidatedUserRegistration + $dateOfBirth + .map { [weak self] dob in + guard let self else { return .invalid } + switch dateOfBirthValidateState { + case .empty: + return .filling + case .filling: + if self.validate(dateOfBirth: dob) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(dateOfBirth: dob) + } + } + .assign(to: \.dateOfBirthValidateState, on: self) + .store(in: &disposeBag) + $name - .map { name in + .map { [weak self] name in guard !name.isEmpty else { return .empty } - return .valid + guard let self else { return .invalid } + switch self.displayNameValidateState { + case .empty: + return .filling + case .filling: + if self.validate(displayName: name) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(displayName: name) + } } .assign(to: \.displayNameValidateState, on: self) .store(in: &disposeBag) $username .removeDuplicates() - .map { username in + .map { [weak self] username in guard !username.isEmpty else { return .empty } - var isValid = true - - // regex opt-out way to check validation - // allowed: - // a-z (isASCII && isLetter) - // A-Z (isASCII && isLetter) - // 0-9 (isASCII && isNumber) - // _ ("_") - for char in username { - guard char.isASCII, char.isLetter || char.isNumber || char == "_" else { - isValid = false - break + guard let self else { return .invalid } + switch self.usernameValidateState { + case .empty: + return .filling + case .filling: + if self.validate(handle: username) == .valid { + return .valid + } else { + return .filling } + case .invalid, .valid: + return self.validate(handle: username) } - return isValid ? .valid : .invalid } .assign(to: \.usernameValidateState, on: self) .store(in: &disposeBag) @@ -145,31 +200,79 @@ final class MastodonRegisterViewModel: ObservableObject { .store(in: &disposeBag) $email - .map { email in + .map { [weak self] email in guard !email.isEmpty else { return .empty } - return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid + guard let self else { return .invalid } + switch self.emailValidateState { + case .empty: + return .filling + case .filling: + if self.validate(email: email) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(email: email) + } } .assign(to: \.emailValidateState, on: self) .store(in: &disposeBag) - Publishers.CombineLatest($password, $passwordConfirmation) - .map { password, confirmation in - guard !password.isEmpty && !confirmation.isEmpty else { return .empty } - - if password.count >= 8 && password == confirmation { - return .valid - } else { - return .invalid + $password + .map { [weak self] password in + guard !password.isEmpty else { return .empty } + guard let self else { return .invalid } + switch self.passwordBaseValidateState { + case .empty: + return .filling + case .filling: + if self.validate(password: password) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(password: password) } } - .assign(to: \.passwordValidateState, on: self) + .assign(to: \.passwordBaseValidateState, on: self) + .store(in: &disposeBag) + + Publishers.CombineLatest($password, $passwordConfirmation) + .map { [weak self] password, confirmation in + guard !password.isEmpty && !confirmation.isEmpty else { return .empty } + guard let self else { return .invalid } + switch self.passwordConfirmationValidateState { + case .empty, .filling: + if self.validate(password: password, confirmation: confirmation) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(password: password, confirmation: confirmation) + } + } + .assign(to: \.passwordConfirmationValidateState, on: self) .store(in: &disposeBag) if approvalRequired { $reason - .map { invite in - guard !invite.isEmpty else { return .empty } - return .valid + .map { joinReason in + guard !joinReason.isEmpty else { return .empty } + switch self.reasonValidateState { + case .empty: + return .filling + case .filling: + if self.validate(reason: joinReason) == .valid { + return .valid + } else { + return .filling + } + case .invalid, .valid: + return self.validate(reason: joinReason) + } } .assign(to: \.reasonValidateState, on: self) .store(in: &disposeBag) @@ -188,7 +291,7 @@ final class MastodonRegisterViewModel: ObservableObject { self.emailErrorPrompt = details.emailErrorDescriptions.first details.emailErrorDescriptions.first.flatMap { _ in self.emailValidateState = .invalid } self.passwordErrorPrompt = details.passwordErrorDescriptions.first - details.passwordErrorDescriptions.first.flatMap { _ in self.passwordValidateState = .invalid } + details.passwordErrorDescriptions.first.flatMap { _ in self.passwordBaseValidateState = .invalid } self.reasonErrorPrompt = details.reasonErrorDescriptions.first details.reasonErrorDescriptions.first.flatMap { _ in self.reasonValidateState = .invalid } } else { @@ -204,7 +307,7 @@ final class MastodonRegisterViewModel: ObservableObject { $usernameValidateState, $displayNameValidateState, $emailValidateState, - $passwordValidateState + $passwordBaseValidateState ) .map { $0.0 == .valid && @@ -213,9 +316,17 @@ final class MastodonRegisterViewModel: ObservableObject { $0.3 == .valid } - let publisherTwo = $reasonValidateState.map { reasonValidateState -> Bool in - guard self.approvalRequired else { return true } - return reasonValidateState == .valid + let publisherTwo = Publishers.CombineLatest3( + $reasonValidateState, + $dateOfBirthValidateState, + $passwordConfirmationValidateState + ) + .map { [weak self] reasonValidateState, dobValidateState, passwordConfirmationValidateState -> Bool in + guard let self else { return false } + let reasonOK = !self.approvalRequired || reasonValidateState == .valid + let dobOK = (self.minAge == nil) || dobValidateState == .valid + let passwordConfirmationCorrect = passwordConfirmationValidateState == .valid + return reasonOK && dobOK && passwordConfirmationCorrect } Publishers.CombineLatest( @@ -225,25 +336,123 @@ final class MastodonRegisterViewModel: ObservableObject { .map { $0 && $1 } .assign(to: \.isAllValid, on: self) .store(in: &disposeBag) + + Publishers.CombineLatest4( + publisherOne, + $reasonValidateState, + $passwordConfirmationValidateState, + $dateOfBirthValidateState + ) + .sink { [weak self] publisherOne, reasonValidState, passwordConfirmValidState, dobValidState in + if publisherOne == false { return } + if reasonValidState == .valid && passwordConfirmValidState == .valid && dobValidState != .valid { + self?.dateOfBirthValidateState = .invalid // this will highlight the DOB field if everything else has been filled in + } + } + .store(in: &disposeBag) } } extension MastodonRegisterViewModel { enum ValidateState: Hashable { case empty + case filling case invalid case valid } + + static func isValidEmail(_ email: String) -> Bool { + let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + + let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx) + return emailPred.evaluate(with: email) + } + + func validate(_ field: RegistrationField) { + let state = validationState(forCurrentContentsOf: field) + switch field { + case .displayName: + displayNameValidateState = state + case .handle: + usernameValidateState = state + case .email: + emailValidateState = state + case .password: + passwordBaseValidateState = state + case .confirmPassword: + passwordConfirmationValidateState = state + case .dateOfBirth: + dateOfBirthValidateState = state + case .proposedApprovalReason: + reasonValidateState = state + } + } + + private func validate(dateOfBirth: Date) -> ValidateState { + guard let minAge else { return .valid } + let years = Calendar.current.dateComponents([.year], from: dateOfBirth, to: Date.now).year ?? 0 + print("looks to be \(years) old") + return years < minAge ? .invalid : .valid + } + + private func validate(displayName: String) -> ValidateState { + return displayName.isEmpty ? .empty : .valid + } + + private func validate(handle: String) -> ValidateState { + var isValid = true + // regex opt-out way to check validation + // allowed: + // a-z (isASCII && isLetter) + // A-Z (isASCII && isLetter) + // 0-9 (isASCII && isNumber) + // _ ("_") + for char in handle { + guard char.isASCII, char.isLetter || char.isNumber || char == "_" else { + isValid = false + break + } + } + return isValid ? .valid : .invalid + } + + private func validate(email: String) -> ValidateState { + return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid + } + + private func validate(password: String) -> ValidateState { + return password.count >= 8 ? .valid : .invalid + } + + private func validate(password: String, confirmation: String) -> ValidateState { + return password == passwordConfirmation ? .valid : .invalid + } + + private func validate(reason: String) -> ValidateState { + return reason.isEmpty ? .invalid : .valid + } + + private func validationState(forCurrentContentsOf field: RegistrationField) -> ValidateState { + switch field { + case .displayName: + return validate(displayName: name) + case .handle: + return validate(handle: username) + case .email: + return validate(email: email) + case .password: + return validate(password: password) + case .confirmPassword: + return validate(password: password, confirmation: passwordConfirmation) + case .dateOfBirth: + return validate(dateOfBirth: dateOfBirth) + case .proposedApprovalReason: + return validate(reason: reason) + } + } } extension MastodonRegisterViewModel { - static func isValidEmail(_ email: String) -> Bool { - let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" - - let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx) - return emailPred.evaluate(with: email) - } - static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { let configuration = UIImage.SymbolConfiguration(font: font) return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)! @@ -294,3 +503,25 @@ extension MastodonRegisterViewModel { return "@\(username)@\(domain)" } } + +protocol RegistrationInstance { + var approvalRequired: Bool? { get } + var minAge: Int? { get } + var isBeyondVersion1: Bool { get } + var isOpenToNewRegistrations: Bool? { get } + var rules: [Mastodon.Entity.Instance.Rule]? { get } +} + +extension Mastodon.Entity.Instance: RegistrationInstance { + var minAge: Int? { return nil } + var isBeyondVersion1: Bool { + return version?.majorServerVersion(greaterThanOrEquals: 4) ?? false + } + var isOpenToNewRegistrations: Bool? { return registrations } +} + +extension Mastodon.Entity.V2.Instance: RegistrationInstance { + var minAge: Int? { return registrations?.minAge } + var isBeyondVersion1: Bool { return true } + var isOpenToNewRegistrations: Bool? { return registrations?.enabled } +} diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index 576972b51..50e87def7 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -103,9 +103,17 @@ extension AuthenticationViewModel { stateStreamContinuation.yield(.joiningServer(server)) - let instance = try await APIService.shared.instance(domain: server.domain, authenticationBox: nil) + let instance: RegistrationInstance + do { + instance = try await APIService.shared.instanceV2(domain: server.domain, authenticationBox: nil) + } catch { + instance = try await APIService.shared.instance(domain: server.domain, authenticationBox: nil) + if instance.isBeyondVersion1 { + throw APIService.APIError.explicit(.badResponse) + } + } - guard instance.registrations != false else { + guard instance.isOpenToNewRegistrations ?? true else { throw AuthenticationViewModel.AuthenticationError.registrationClosed } let application = try await APIService.shared.createApplication(domain: server.domain) @@ -157,10 +165,11 @@ extension AuthenticationViewModel { assert(hasAgreedToRules == true) let query = Mastodon.API.Account.RegisterQuery( reason: info.reason, + dateOfBirth: info.minAge == nil ? nil : info.dateOfBirth, username: info.username, email: info.email, password: info.password, - agreement: hasAgreedToRules, + agreement: hasAgreedToRules, locale: locale ?? self.locale ) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index ecb4aafd2..91e96a3b0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -49,22 +49,39 @@ extension Mastodon.API.Account { public struct RegisterQuery: Codable, PostQuery { public let reason: String? + public let dateOfBirth: String? public let username: String public let email: String public let password: String public let agreement: Bool public let locale: String - public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { + public init(reason: String? = nil, dateOfBirth: Date?, username: String, email: String, password: String, agreement: Bool, locale: String) { self.reason = reason + if let dateOfBirth { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withFullDate // YYYY-MM-DD + self.dateOfBirth = dateFormatter.string(from: dateOfBirth) + } else { + self.dateOfBirth = nil + } self.username = username self.email = email self.password = password self.agreement = agreement self.locale = locale } + + public enum CodingKeys: String, CodingKey { + case reason + case dateOfBirth = "date_of_birth" + case username + case email + case password + case agreement + case locale + } } - } extension Mastodon.API.Account {