Registrations wip

This commit is contained in:
Justin Mazzocchi 2020-09-11 02:55:06 -07:00
parent 757e8dba35
commit 2745f2470d
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
13 changed files with 433 additions and 80 deletions

View file

@ -5,7 +5,19 @@
"add-identity.instance-url" = "Instance URL"; "add-identity.instance-url" = "Instance URL";
"add-identity.log-in" = "Log in"; "add-identity.log-in" = "Log in";
"add-identity.browse" = "Browse"; "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"; "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.manage-accounts" = "Manage Accounts";
"secondary-navigation.lists" = "Lists"; "secondary-navigation.lists" = "Lists";
"secondary-navigation.preferences" = "Preferences"; "secondary-navigation.preferences" = "Preferences";

View file

@ -6,41 +6,67 @@ import Mastodon
public enum AccessTokenEndpoint { public enum AccessTokenEndpoint {
case oauthToken( case oauthToken(
clientID: String, clientID: String,
clientSecret: String, clientSecret: String,
code: String, grantType: String,
grantType: String, scopes: String,
scopes: String, code: String?,
redirectURI: String redirectURI: String?
) )
case accounts(username: String, email: String, password: String, reason: String?)
} }
extension AccessTokenEndpoint: Endpoint { extension AccessTokenEndpoint: Endpoint {
public typealias ResultType = AccessToken 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] { public var pathComponentsInContext: [String] {
["oauth", "token"] switch self {
case .oauthToken:
return ["oauth", "token"]
case .accounts:
return ["accounts"]
}
} }
public var method: HTTPMethod { public var method: HTTPMethod {
switch self { switch self {
case .oauthToken: return .post case .oauthToken, .accounts: return .post
} }
} }
public var parameters: [String: Any]? { public var parameters: [String: Any]? {
switch self { switch self {
case let .oauthToken(clientID, clientSecret, code, grantType, scopes, redirectURI): case let .oauthToken(clientID, clientSecret, grantType, scopes, code, redirectURI):
return [ var params = [
"client_id": clientID, "client_id": clientID,
"client_secret": clientSecret, "client_secret": clientSecret,
"code": code,
"grant_type": grantType, "grant_type": grantType,
"scope": scopes, "scope": scopes]
"redirect_uri": redirectURI
] 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
} }
} }
} }

View file

@ -6,16 +6,13 @@ import Stubbing
extension AccessTokenEndpoint: Stubbing { extension AccessTokenEndpoint: Stubbing {
public func dataString(url: URL) -> String? { public func dataString(url: URL) -> String? {
switch self { """
case let .oauthToken(_, _, _, _, scopes, _): {
return """ "access_token": "ACCESS_TOKEN_STUB_VALUE",
{ "token_type": "Bearer",
"access_token": "ACCESS_TOKEN_STUB_VALUE", "scope": "ACCESS_TOKEN_STUB_VALUE_SCOPES",
"token_type": "Bearer", "created_at": "\(Int(Date().timeIntervalSince1970))"
"scope": "\(scopes)",
"created_at": "\(Int(Date().timeIntervalSince1970))"
}
"""
} }
"""
} }
} }

View file

@ -11,7 +11,9 @@
D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; }; D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; };
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.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 */; }; 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 */; }; D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; }; D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.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>"; }; 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>"; }; 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>"; }; 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; }; 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; }; 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>"; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; 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>"; }; D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; 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>"; }; D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
@ -253,7 +257,9 @@
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
D0B32F4F250B373600311912 /* RegistrationView.swift */,
D0C7D42724F76169001EBDBB /* RootView.swift */, D0C7D42724F76169001EBDBB /* RootView.swift */,
D02E1F94250B13210071AD56 /* SafariView.swift */,
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
D01F41D324F8807E00D55A2D /* Status Cell */, D01F41D324F8807E00D55A2D /* Status Cell */,
D0C7D42524F76169001EBDBB /* StatusListView.swift */, D0C7D42524F76169001EBDBB /* StatusListView.swift */,
@ -479,7 +485,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,

View file

@ -29,33 +29,26 @@ public extension AllIdentitiesService {
} }
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> { func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
let secrets = Secrets(identityID: id, keychain: environment.keychain) createIdentity(
do {
try secrets.setInstanceURL(url)
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
let createIdentityPublisher = database.createIdentity(
id: id, id: id,
url: url, url: url,
authenticated: authenticated) authenticationPublisher: authenticated
.ignoreOutput() ? AuthenticationService(url: url, environment: environment).authenticate()
.eraseToAnyPublisher() : nil)
}
if authenticated { func createIdentity(
return AuthenticationService(url: url, environment: environment).authenticate() id: UUID,
.tryMap { url: URL,
try secrets.setClientID($0.clientId) username: String,
try secrets.setClientSecret($0.clientSecret) email: String,
try secrets.setAccessToken($1.accessToken) password: String,
} reason: String?) -> AnyPublisher<Never, Error> {
.flatMap { createIdentityPublisher } createIdentity(
.eraseToAnyPublisher() id: id,
} else { url: url,
return createIdentityPublisher authenticationPublisher: AuthenticationService(url: url, environment: environment)
} .register(username: username, email: email, password: password, reason: reason))
} }
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> { func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
@ -101,3 +94,38 @@ public extension AllIdentitiesService {
.eraseToAnyPublisher() .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
}
}
}

View file

@ -22,16 +22,42 @@ struct AuthenticationService {
extension AuthenticationService { extension AuthenticationService {
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> { func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let appAuthorization = mastodonAPIClient.request( let authorization = appAuthorization().share()
AppAuthorizationEndpoint.apps(
clientName: OAuth.clientName, return authorization
redirectURI: OAuth.callbackURL.absoluteString, .zip(authorization.flatMap(authenticate(appAuthorization:)))
scopes: OAuth.scopes, .eraseToAnyPublisher()
website: OAuth.website)) }
func register(username: String,
email: String,
password: String,
reason: String?) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let authorization = appAuthorization()
.share() .share()
return appAuthorization return authorization.zip(
.zip(appAuthorization.flatMap(authenticate(appAuthorization:))) 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() .eraseToAnyPublisher()
} }
} }
@ -41,7 +67,8 @@ private extension AuthenticationService {
static let clientName = "Metatext" static let clientName = "Metatext"
static let scopes = "read write follow push" static let scopes = "read write follow push"
static let codeCallbackQueryItemName = "code" 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 callbackURLScheme = "metatext"
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")! static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
static let website = URL(string: "https://metabolist.com/metatext")! static let website = URL(string: "https://metabolist.com/metatext")!
@ -63,6 +90,15 @@ private extension AuthenticationService {
return code 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 { func authorizationURL(appAuthorization: AppAuthorization) throws -> URL {
guard var authorizationURLComponents = URLComponents( guard var authorizationURLComponents = URLComponents(
url: mastodonAPIClient.instanceURL, url: mastodonAPIClient.instanceURL,
@ -106,9 +142,9 @@ private extension AuthenticationService {
AccessTokenEndpoint.oauthToken( AccessTokenEndpoint.oauthToken(
clientID: appAuthorization.clientId, clientID: appAuthorization.clientId,
clientSecret: appAuthorization.clientSecret, clientSecret: appAuthorization.clientSecret,
code: $0, grantType: OAuth.authorizationCodeGrantType,
grantType: OAuth.grantType,
scopes: OAuth.scopes, scopes: OAuth.scopes,
code: $0,
redirectURI: OAuth.callbackURL.absoluteString)) redirectURI: OAuth.callbackURL.absoluteString))
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()

View file

@ -15,25 +15,21 @@ import ViewModels
let db: IdentityDatabase = { let db: IdentityDatabase = {
let id = UUID() let id = UUID()
let url = URL(string: "https://mastodon.social")!
let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self) 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) let secrets = Secrets(identityID: id, keychain: MockKeychain.self)
try! secrets.setInstanceURL(url) try! secrets.setInstanceURL(.previewInstanceURL)
try! secrets.setAccessToken(UUID().uuidString) 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) .receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
_ = db.updateInstance(instance, forIdentityID: id) _ = db.updateInstance(.preview, forIdentityID: id)
.receive(on: ImmediateScheduler.shared) .receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
_ = db.updateAccount(account, forIdentityID: id) _ = db.updateAccount(.preview, forIdentityID: id)
.receive(on: ImmediateScheduler.shared) .receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
@ -41,6 +37,19 @@ let db: IdentityDatabase = {
}() }()
let environment = AppEnvironment.mock(fixtureDatabase: db) 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 { public extension RootViewModel {
static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() } static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() }

View file

@ -13,7 +13,7 @@ public final class AddIdentityViewModel: ObservableObject {
@Published public var urlFieldText = "" @Published public var urlFieldText = ""
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
@Published public private(set) var loading = false @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 @Published public private(set) var isPublicTimelineAvailable = false
public let addedIdentityID: AnyPublisher<UUID, Never> public let addedIdentityID: AnyPublisher<UUID, Never>
@ -32,18 +32,23 @@ public final class AddIdentityViewModel: ObservableObject {
.removeDuplicates() .removeDuplicates()
.map(instanceURLService.url(text:)) .map(instanceURLService.url(text:))
.share() .share()
let urlPresent = url.map { $0 != nil }.share()
url.compactMap { $0 } url.compactMap { $0 }
.flatMap(instanceURLService.instance(url:)) .flatMap(instanceURLService.instance(url:))
.combineLatest(urlPresent) .combineLatest(url)
.map { $1 ? $0 : nil } .map {
if let instance = $0, let url = $1 {
return (instance, url)
}
return nil
}
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$instance) .assign(to: &$instanceAndURL)
url.compactMap { $0 } url.compactMap { $0 }
.flatMap(instanceURLService.isPublicTimelineAvailable(url:)) .flatMap(instanceURLService.isPublicTimelineAvailable(url:))
.combineLatest(urlPresent) .combineLatest(url.map { $0 != nil })
.map { $0 && $1 } .map { $0 && $1 }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$isPublicTimelineAvailable) .assign(to: &$isPublicTimelineAvailable)
@ -64,6 +69,10 @@ public extension AddIdentityViewModel {
.sink { _ in } .sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
} }
func registrationViewModel(instance: Instance, url: URL) -> RegistrationViewModel {
RegistrationViewModel(instance: instance, url: url, allIdentitiesService: allIdentitiesService)
}
} }
private extension AddIdentityViewModel { private extension AddIdentityViewModel {

View 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)
}
}

View file

@ -7,6 +7,7 @@ import ViewModels
struct AddIdentityView: View { struct AddIdentityView: View {
@StateObject var viewModel: AddIdentityViewModel @StateObject var viewModel: AddIdentityViewModel
@EnvironmentObject var rootViewModel: RootViewModel @EnvironmentObject var rootViewModel: RootViewModel
@State private var navigateToRegister = false
var body: some View { var body: some View {
Form { Form {
@ -15,7 +16,7 @@ struct AddIdentityView: View {
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.keyboardType(.URL) .keyboardType(.URL)
if let instance = viewModel.instance { if let (instance, _) = viewModel.instanceAndURL {
VStack(alignment: .center) { VStack(alignment: .center) {
KFImage(instance.thumbnail) KFImage(instance.thumbnail)
.placeholder { .placeholder {
@ -41,6 +42,25 @@ struct AddIdentityView: View {
} else { } else {
Button("add-identity.log-in", Button("add-identity.log-in",
action: viewModel.logInTapped) 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 { if viewModel.isPublicTimelineAvailable {
Button("add-identity.browse", action: viewModel.browseTapped) Button("add-identity.browse", action: viewModel.browseTapped)
} }
@ -71,7 +91,10 @@ import PreviewViewModels
struct AddAccountView_Previews: PreviewProvider { struct AddAccountView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel()) NavigationView {
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
.navigationBarTitleDisplayMode(.inline)
}
} }
} }
#endif #endif

View 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

View file

@ -13,9 +13,13 @@ struct RootView: View {
.environmentObject(viewModel) .environmentObject(viewModel)
.transition(.opacity) .transition(.opacity)
} else { } else {
AddIdentityView(viewModel: viewModel.addIdentityViewModel()) NavigationView {
.environmentObject(viewModel) AddIdentityView(viewModel: viewModel.addIdentityViewModel())
.transition(.opacity) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
}
.environmentObject(viewModel)
.transition(.opacity)
} }
} }
} }

16
Views/SafariView.swift Normal file
View 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) {
}
}