mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Registrations wip
This commit is contained in:
parent
757e8dba35
commit
2745f2470d
13 changed files with 433 additions and 80 deletions
|
@ -5,7 +5,19 @@
|
|||
"add-identity.instance-url" = "Instance URL";
|
||||
"add-identity.log-in" = "Log in";
|
||||
"add-identity.browse" = "Browse";
|
||||
"add-identity.join" = "Join";
|
||||
"add-identity.request-invite" = "Request an invite";
|
||||
"add-identity.unable-to-connect-to-instance" = "Unable to connect to instance";
|
||||
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
|
||||
"registration.username" = "Username";
|
||||
"registration.email" = "Email";
|
||||
"registration.password" = "Password";
|
||||
"registration.password-confirmation" = "Confirm password";
|
||||
"registration.reason-%@" = "Why do you want to join %@?";
|
||||
"registration.server-rules" = "Server rules";
|
||||
"registration.terms-of-service" = "Terms of service";
|
||||
"registration.agree-to-server-rules-and-terms-of-service" = "I agree to the server rules and terms of service";
|
||||
"registration.password-confirmation-mismatch" = "Password and password confirmation do not match";
|
||||
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
||||
"secondary-navigation.lists" = "Lists";
|
||||
"secondary-navigation.preferences" = "Preferences";
|
||||
|
|
|
@ -6,41 +6,67 @@ import Mastodon
|
|||
|
||||
public enum AccessTokenEndpoint {
|
||||
case oauthToken(
|
||||
clientID: String,
|
||||
clientSecret: String,
|
||||
code: String,
|
||||
grantType: String,
|
||||
scopes: String,
|
||||
redirectURI: String
|
||||
)
|
||||
clientID: String,
|
||||
clientSecret: String,
|
||||
grantType: String,
|
||||
scopes: String,
|
||||
code: String?,
|
||||
redirectURI: String?
|
||||
)
|
||||
case accounts(username: String, email: String, password: String, reason: String?)
|
||||
}
|
||||
|
||||
extension AccessTokenEndpoint: Endpoint {
|
||||
public typealias ResultType = AccessToken
|
||||
|
||||
public var context: [String] { [] }
|
||||
public var context: [String] {
|
||||
switch self {
|
||||
case .oauthToken:
|
||||
return []
|
||||
case .accounts:
|
||||
return defaultContext
|
||||
}
|
||||
}
|
||||
|
||||
public var pathComponentsInContext: [String] {
|
||||
["oauth", "token"]
|
||||
switch self {
|
||||
case .oauthToken:
|
||||
return ["oauth", "token"]
|
||||
case .accounts:
|
||||
return ["accounts"]
|
||||
}
|
||||
}
|
||||
|
||||
public var method: HTTPMethod {
|
||||
switch self {
|
||||
case .oauthToken: return .post
|
||||
case .oauthToken, .accounts: return .post
|
||||
}
|
||||
}
|
||||
|
||||
public var parameters: [String: Any]? {
|
||||
switch self {
|
||||
case let .oauthToken(clientID, clientSecret, code, grantType, scopes, redirectURI):
|
||||
return [
|
||||
case let .oauthToken(clientID, clientSecret, grantType, scopes, code, redirectURI):
|
||||
var params = [
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"code": code,
|
||||
"grant_type": grantType,
|
||||
"scope": scopes,
|
||||
"redirect_uri": redirectURI
|
||||
]
|
||||
"scope": scopes]
|
||||
|
||||
params["code"] = code
|
||||
params["redirect_uri"] = redirectURI
|
||||
|
||||
return params
|
||||
case let .accounts(username, email, password, reason):
|
||||
var params: [String: Any] = [
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"locale": Locale.autoupdatingCurrent.languageCode ?? "en", // TODO: probably need to map
|
||||
"agreement": true]
|
||||
|
||||
params["reason"] = reason
|
||||
|
||||
return params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,16 +6,13 @@ import Stubbing
|
|||
|
||||
extension AccessTokenEndpoint: Stubbing {
|
||||
public func dataString(url: URL) -> String? {
|
||||
switch self {
|
||||
case let .oauthToken(_, _, _, _, scopes, _):
|
||||
return """
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN_STUB_VALUE",
|
||||
"token_type": "Bearer",
|
||||
"scope": "\(scopes)",
|
||||
"created_at": "\(Int(Date().timeIntervalSince1970))"
|
||||
}
|
||||
"""
|
||||
"""
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN_STUB_VALUE",
|
||||
"token_type": "Bearer",
|
||||
"scope": "ACCESS_TOKEN_STUB_VALUE_SCOPES",
|
||||
"created_at": "\(Int(Date().timeIntervalSince1970))"
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; };
|
||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
|
||||
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
|
||||
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
|
||||
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
|
||||
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
|
||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
|
||||
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
|
||||
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
|
||||
|
@ -82,11 +84,13 @@
|
|||
D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
|
||||
D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; };
|
||||
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
|
||||
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
|
||||
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
|
||||
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
|
||||
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
|
||||
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
||||
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
|
||||
|
@ -253,7 +257,9 @@
|
|||
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
|
||||
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
|
||||
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
|
||||
D0B32F4F250B373600311912 /* RegistrationView.swift */,
|
||||
D0C7D42724F76169001EBDBB /* RootView.swift */,
|
||||
D02E1F94250B13210071AD56 /* SafariView.swift */,
|
||||
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
|
||||
D01F41D324F8807E00D55A2D /* Status Cell */,
|
||||
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
|
||||
|
@ -479,7 +485,9 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
|
||||
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
|
||||
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
|
||||
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
|
||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
|
||||
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
|
||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
||||
|
|
|
@ -29,33 +29,26 @@ public extension AllIdentitiesService {
|
|||
}
|
||||
|
||||
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||
|
||||
do {
|
||||
try secrets.setInstanceURL(url)
|
||||
} catch {
|
||||
return Fail(error: error).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let createIdentityPublisher = database.createIdentity(
|
||||
createIdentity(
|
||||
id: id,
|
||||
url: url,
|
||||
authenticated: authenticated)
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
authenticationPublisher: authenticated
|
||||
? AuthenticationService(url: url, environment: environment).authenticate()
|
||||
: nil)
|
||||
}
|
||||
|
||||
if authenticated {
|
||||
return AuthenticationService(url: url, environment: environment).authenticate()
|
||||
.tryMap {
|
||||
try secrets.setClientID($0.clientId)
|
||||
try secrets.setClientSecret($0.clientSecret)
|
||||
try secrets.setAccessToken($1.accessToken)
|
||||
}
|
||||
.flatMap { createIdentityPublisher }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return createIdentityPublisher
|
||||
}
|
||||
func createIdentity(
|
||||
id: UUID,
|
||||
url: URL,
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
reason: String?) -> AnyPublisher<Never, Error> {
|
||||
createIdentity(
|
||||
id: id,
|
||||
url: url,
|
||||
authenticationPublisher: AuthenticationService(url: url, environment: environment)
|
||||
.register(username: username, email: email, password: password, reason: reason))
|
||||
}
|
||||
|
||||
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
|
||||
|
@ -101,3 +94,38 @@ public extension AllIdentitiesService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AllIdentitiesService {
|
||||
func createIdentity(
|
||||
id: UUID,
|
||||
url: URL,
|
||||
authenticationPublisher: AnyPublisher<(AppAuthorization, AccessToken), Error>?) -> AnyPublisher<Never, Error> {
|
||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||
|
||||
do {
|
||||
try secrets.setInstanceURL(url)
|
||||
} catch {
|
||||
return Fail(error: error).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let createIdentityPublisher = database.createIdentity(
|
||||
id: id,
|
||||
url: url,
|
||||
authenticated: authenticationPublisher != nil)
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
if let authenticationPublisher = authenticationPublisher {
|
||||
return authenticationPublisher
|
||||
.tryMap {
|
||||
try secrets.setClientID($0.clientId)
|
||||
try secrets.setClientSecret($0.clientSecret)
|
||||
try secrets.setAccessToken($1.accessToken)
|
||||
}
|
||||
.flatMap { createIdentityPublisher }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return createIdentityPublisher
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,16 +22,42 @@ struct AuthenticationService {
|
|||
|
||||
extension AuthenticationService {
|
||||
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
||||
let appAuthorization = mastodonAPIClient.request(
|
||||
AppAuthorizationEndpoint.apps(
|
||||
clientName: OAuth.clientName,
|
||||
redirectURI: OAuth.callbackURL.absoluteString,
|
||||
scopes: OAuth.scopes,
|
||||
website: OAuth.website))
|
||||
let authorization = appAuthorization().share()
|
||||
|
||||
return authorization
|
||||
.zip(authorization.flatMap(authenticate(appAuthorization:)))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func register(username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
reason: String?) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
||||
let authorization = appAuthorization()
|
||||
.share()
|
||||
|
||||
return appAuthorization
|
||||
.zip(appAuthorization.flatMap(authenticate(appAuthorization:)))
|
||||
return authorization.zip(
|
||||
authorization.flatMap { appAuthorization -> AnyPublisher<AccessToken, Error> in
|
||||
mastodonAPIClient.request(
|
||||
AccessTokenEndpoint.oauthToken(
|
||||
clientID: appAuthorization.clientId,
|
||||
clientSecret: appAuthorization.clientSecret,
|
||||
grantType: OAuth.registrationGrantType,
|
||||
scopes: OAuth.scopes,
|
||||
code: nil,
|
||||
redirectURI: OAuth.callbackURL.absoluteString))
|
||||
.flatMap { accessToken -> AnyPublisher<AccessToken, Error> in
|
||||
mastodonAPIClient.accessToken = accessToken.accessToken
|
||||
|
||||
return mastodonAPIClient.request(
|
||||
AccessTokenEndpoint.accounts(
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
reason: reason))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
@ -41,7 +67,8 @@ private extension AuthenticationService {
|
|||
static let clientName = "Metatext"
|
||||
static let scopes = "read write follow push"
|
||||
static let codeCallbackQueryItemName = "code"
|
||||
static let grantType = "authorization_code"
|
||||
static let authorizationCodeGrantType = "authorization_code"
|
||||
static let registrationGrantType = "client_credentials"
|
||||
static let callbackURLScheme = "metatext"
|
||||
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
|
||||
static let website = URL(string: "https://metabolist.com/metatext")!
|
||||
|
@ -63,6 +90,15 @@ private extension AuthenticationService {
|
|||
return code
|
||||
}
|
||||
|
||||
func appAuthorization() -> AnyPublisher<AppAuthorization, Error> {
|
||||
mastodonAPIClient.request(
|
||||
AppAuthorizationEndpoint.apps(
|
||||
clientName: OAuth.clientName,
|
||||
redirectURI: OAuth.callbackURL.absoluteString,
|
||||
scopes: OAuth.scopes,
|
||||
website: OAuth.website))
|
||||
}
|
||||
|
||||
func authorizationURL(appAuthorization: AppAuthorization) throws -> URL {
|
||||
guard var authorizationURLComponents = URLComponents(
|
||||
url: mastodonAPIClient.instanceURL,
|
||||
|
@ -106,9 +142,9 @@ private extension AuthenticationService {
|
|||
AccessTokenEndpoint.oauthToken(
|
||||
clientID: appAuthorization.clientId,
|
||||
clientSecret: appAuthorization.clientSecret,
|
||||
code: $0,
|
||||
grantType: OAuth.grantType,
|
||||
grantType: OAuth.authorizationCodeGrantType,
|
||||
scopes: OAuth.scopes,
|
||||
code: $0,
|
||||
redirectURI: OAuth.callbackURL.absoluteString))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
|
|
@ -15,25 +15,21 @@ import ViewModels
|
|||
|
||||
let db: IdentityDatabase = {
|
||||
let id = UUID()
|
||||
let url = URL(string: "https://mastodon.social")!
|
||||
let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self)
|
||||
let decoder = MastodonDecoder()
|
||||
let instance = try! decoder.decode(Instance.self, from: StubData.instance)
|
||||
let account = try! decoder.decode(Account.self, from: StubData.account)
|
||||
let secrets = Secrets(identityID: id, keychain: MockKeychain.self)
|
||||
|
||||
try! secrets.setInstanceURL(url)
|
||||
try! secrets.setInstanceURL(.previewInstanceURL)
|
||||
try! secrets.setAccessToken(UUID().uuidString)
|
||||
|
||||
_ = db.createIdentity(id: id, url: url, authenticated: true)
|
||||
_ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true)
|
||||
.receive(on: ImmediateScheduler.shared)
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
|
||||
_ = db.updateInstance(instance, forIdentityID: id)
|
||||
_ = db.updateInstance(.preview, forIdentityID: id)
|
||||
.receive(on: ImmediateScheduler.shared)
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
|
||||
_ = db.updateAccount(account, forIdentityID: id)
|
||||
_ = db.updateAccount(.preview, forIdentityID: id)
|
||||
.receive(on: ImmediateScheduler.shared)
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
|
||||
|
@ -41,6 +37,19 @@ let db: IdentityDatabase = {
|
|||
}()
|
||||
|
||||
let environment = AppEnvironment.mock(fixtureDatabase: db)
|
||||
let decoder = MastodonDecoder()
|
||||
|
||||
public extension URL {
|
||||
static let previewInstanceURL = URL(string: "https://mastodon.social")!
|
||||
}
|
||||
|
||||
public extension Account {
|
||||
static let preview = try! decoder.decode(Account.self, from: StubData.account)
|
||||
}
|
||||
|
||||
public extension Instance {
|
||||
static let preview = try! decoder.decode(Instance.self, from: StubData.instance)
|
||||
}
|
||||
|
||||
public extension RootViewModel {
|
||||
static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() }
|
||||
|
|
|
@ -13,7 +13,7 @@ public final class AddIdentityViewModel: ObservableObject {
|
|||
@Published public var urlFieldText = ""
|
||||
@Published public var alertItem: AlertItem?
|
||||
@Published public private(set) var loading = false
|
||||
@Published public private(set) var instance: Instance?
|
||||
@Published public private(set) var instanceAndURL: (Instance, URL)?
|
||||
@Published public private(set) var isPublicTimelineAvailable = false
|
||||
public let addedIdentityID: AnyPublisher<UUID, Never>
|
||||
|
||||
|
@ -32,18 +32,23 @@ public final class AddIdentityViewModel: ObservableObject {
|
|||
.removeDuplicates()
|
||||
.map(instanceURLService.url(text:))
|
||||
.share()
|
||||
let urlPresent = url.map { $0 != nil }.share()
|
||||
|
||||
url.compactMap { $0 }
|
||||
.flatMap(instanceURLService.instance(url:))
|
||||
.combineLatest(urlPresent)
|
||||
.map { $1 ? $0 : nil }
|
||||
.combineLatest(url)
|
||||
.map {
|
||||
if let instance = $0, let url = $1 {
|
||||
return (instance, url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$instance)
|
||||
.assign(to: &$instanceAndURL)
|
||||
|
||||
url.compactMap { $0 }
|
||||
.flatMap(instanceURLService.isPublicTimelineAvailable(url:))
|
||||
.combineLatest(urlPresent)
|
||||
.combineLatest(url.map { $0 != nil })
|
||||
.map { $0 && $1 }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isPublicTimelineAvailable)
|
||||
|
@ -64,6 +69,10 @@ public extension AddIdentityViewModel {
|
|||
.sink { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func registrationViewModel(instance: Instance, url: URL) -> RegistrationViewModel {
|
||||
RegistrationViewModel(instance: instance, url: url, allIdentitiesService: allIdentitiesService)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddIdentityViewModel {
|
||||
|
|
82
ViewModels/Sources/ViewModels/RegistrationViewModel.swift
Normal file
82
ViewModels/Sources/ViewModels/RegistrationViewModel.swift
Normal file
|
@ -0,0 +1,82 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Mastodon
|
||||
import MastodonAPI
|
||||
import ServiceLayer
|
||||
|
||||
public enum RegistrationError: Error {
|
||||
case passwordConfirmationMismatch
|
||||
}
|
||||
|
||||
public final class RegistrationViewModel: ObservableObject {
|
||||
public let instance: Instance
|
||||
public let serverRulesURL: URL
|
||||
public let termsOfServiceURL: URL
|
||||
@Published public var alertItem: AlertItem?
|
||||
@Published public var username = ""
|
||||
@Published public var email = ""
|
||||
@Published public var password = ""
|
||||
@Published public var passwordConfirmation = ""
|
||||
@Published public var reason = ""
|
||||
@Published public var passwordsMatch = false
|
||||
@Published public var agreement = false
|
||||
@Published public private(set) var registerButtonEnabled = false
|
||||
@Published public private(set) var registering = false
|
||||
|
||||
private let url: URL
|
||||
private let allIdentitiesService: AllIdentitiesService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(instance: Instance, url: URL, allIdentitiesService: AllIdentitiesService) {
|
||||
self.instance = instance
|
||||
self.url = url
|
||||
self.serverRulesURL = url.appendingPathComponent("about").appendingPathComponent("more")
|
||||
self.termsOfServiceURL = url.appendingPathComponent("terms")
|
||||
self.allIdentitiesService = allIdentitiesService
|
||||
|
||||
Publishers.CombineLatest4($username, $email, $password, $reason)
|
||||
.map { username, email, password, reason in
|
||||
!username.isEmpty
|
||||
&& !email.isEmpty
|
||||
&& !password.isEmpty
|
||||
&& (!instance.approvalRequired || !reason.isEmpty)
|
||||
}
|
||||
.combineLatest($agreement)
|
||||
.map { $0 && $1 }
|
||||
.assign(to: &$registerButtonEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
public extension RegistrationViewModel {
|
||||
func registerTapped() {
|
||||
guard password == passwordConfirmation else {
|
||||
alertItem = AlertItem(error: RegistrationError.passwordConfirmationMismatch)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
allIdentitiesService.createIdentity(
|
||||
id: UUID(),
|
||||
url: url,
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
reason: reason)
|
||||
.handleEvents(receiveSubscription: { [weak self] _ in self?.registering = true })
|
||||
.mapError { error -> Error in
|
||||
if error is URLError {
|
||||
return AddIdentityError.unableToConnectToInstance
|
||||
} else {
|
||||
return error
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { [weak self] _ in
|
||||
self?.registering = false
|
||||
} receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import ViewModels
|
|||
struct AddIdentityView: View {
|
||||
@StateObject var viewModel: AddIdentityViewModel
|
||||
@EnvironmentObject var rootViewModel: RootViewModel
|
||||
@State private var navigateToRegister = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
@ -15,7 +16,7 @@ struct AddIdentityView: View {
|
|||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.URL)
|
||||
if let instance = viewModel.instance {
|
||||
if let (instance, _) = viewModel.instanceAndURL {
|
||||
VStack(alignment: .center) {
|
||||
KFImage(instance.thumbnail)
|
||||
.placeholder {
|
||||
|
@ -41,6 +42,25 @@ struct AddIdentityView: View {
|
|||
} else {
|
||||
Button("add-identity.log-in",
|
||||
action: viewModel.logInTapped)
|
||||
if let (instance, url) = viewModel.instanceAndURL,
|
||||
instance.registrations {
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
destination: RegistrationView(
|
||||
viewModel: viewModel.registrationViewModel(
|
||||
instance: instance,
|
||||
url: url)),
|
||||
isActive: $navigateToRegister) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
Button(instance.approvalRequired
|
||||
? "add-identity.request-invite"
|
||||
: "add-identity.join") {
|
||||
navigateToRegister.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.isPublicTimelineAvailable {
|
||||
Button("add-identity.browse", action: viewModel.browseTapped)
|
||||
}
|
||||
|
@ -71,7 +91,10 @@ import PreviewViewModels
|
|||
|
||||
struct AddAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
|
||||
NavigationView {
|
||||
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
103
Views/RegistrationView.swift
Normal file
103
Views/RegistrationView.swift
Normal file
|
@ -0,0 +1,103 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
struct RegistrationView: View {
|
||||
@StateObject var viewModel: RegistrationViewModel
|
||||
|
||||
@State private var presentWebView = false
|
||||
@State private var toReview = ToReview.serverRules
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
TextField("registration.username", text: $viewModel.username)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
Text("@" + viewModel.instance.uri)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
TextField("registration.email", text: $viewModel.email)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.emailAddress)
|
||||
SecureField("registration.password", text: $viewModel.password)
|
||||
SecureField("registration.password-confirmation", text: $viewModel.passwordConfirmation)
|
||||
if viewModel.instance.approvalRequired {
|
||||
VStack(alignment: .leading) {
|
||||
Text("registration.reason-\(viewModel.instance.uri)")
|
||||
TextEditor(text: $viewModel.reason)
|
||||
}
|
||||
}
|
||||
Button("registration.server-rules") {
|
||||
toReview = .serverRules
|
||||
presentWebView = true
|
||||
}
|
||||
Button("registration.terms-of-service") {
|
||||
toReview = .termsOfService
|
||||
presentWebView = true
|
||||
}
|
||||
Toggle("registration.agree-to-server-rules-and-terms-of-service",
|
||||
isOn: $viewModel.agreement)
|
||||
}
|
||||
Section {
|
||||
Group {
|
||||
if viewModel.registering {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(viewModel.instance.approvalRequired
|
||||
? "add-identity.request-invite"
|
||||
: "add-identity.join",
|
||||
action: viewModel.registerTapped)
|
||||
.disabled(!viewModel.registerButtonEnabled)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.alertItem($viewModel.alertItem)
|
||||
.sheet(isPresented: $presentWebView) { () -> SafariView in
|
||||
let url: URL
|
||||
|
||||
switch toReview {
|
||||
case .serverRules: url = viewModel.serverRulesURL
|
||||
case .termsOfService: url = viewModel.termsOfServiceURL
|
||||
}
|
||||
|
||||
return SafariView(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension RegistrationView {
|
||||
enum ToReview {
|
||||
case serverRules
|
||||
case termsOfService
|
||||
}
|
||||
}
|
||||
|
||||
extension RegistrationError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .passwordConfirmationMismatch:
|
||||
return NSLocalizedString(
|
||||
"registration.password-confirmation-mismatch",
|
||||
comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import PreviewViewModels
|
||||
|
||||
struct RegistrationView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RegistrationView(viewModel: RootViewModel.preview
|
||||
.addIdentityViewModel()
|
||||
.registrationViewModel(instance: .preview,
|
||||
url: .previewInstanceURL))
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -13,9 +13,13 @@ struct RootView: View {
|
|||
.environmentObject(viewModel)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
AddIdentityView(viewModel: viewModel.addIdentityViewModel())
|
||||
.environmentObject(viewModel)
|
||||
.transition(.opacity)
|
||||
NavigationView {
|
||||
AddIdentityView(viewModel: viewModel.addIdentityViewModel())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
16
Views/SafariView.swift
Normal file
16
Views/SafariView.swift
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
SFSafariViewController(url: url)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue