2021-02-23 15:14:10 +01:00
//
2021-02-26 11:27:47 +01:00
// M a s t o d o n P i c k S e r v e r V i e w M o d e l . s w i f t
2021-02-23 15:14:10 +01:00
// M a s t o d o n
//
// C r e a t e d b y B r a d G a o o n 2 0 2 1 / 2 / 2 3 .
//
import UIKit
2021-02-25 07:09:19 +01:00
import OSLog
2021-02-23 15:14:10 +01:00
import Combine
import MastodonSDK
2021-02-25 07:09:19 +01:00
import CoreDataStack
2021-02-23 15:14:10 +01:00
2021-02-26 11:27:47 +01:00
class MastodonPickServerViewModel : NSObject {
2021-02-23 15:14:10 +01:00
enum PickServerMode {
2021-02-25 09:38:24 +01:00
case signUp
case signIn
2021-02-23 15:14:10 +01:00
}
enum Category {
2021-02-25 09:38:24 +01:00
// ` a l l ` m e a n s s e a r c h f o r a l l c a t e g o r i e s
case all
// ` s o m e ` m e a n s s e a r c h f o r s p e c i f i c c a t e g o r y
case some ( Mastodon . Entity . Category )
2021-02-23 15:14:10 +01:00
var title : String {
switch self {
2021-02-25 09:38:24 +01:00
case . all :
2021-02-23 15:14:10 +01:00
return L10n . Scene . ServerPicker . Button . Category . all
2021-02-25 09:38:24 +01:00
case . some ( let masCategory ) :
2021-02-24 15:47:42 +01:00
// TODO: U s e e m o j i a s p l a c e h o l d e r s
2021-02-23 15:14:10 +01:00
switch masCategory . category {
case . academia :
2021-02-24 15:47:42 +01:00
return " 📚 "
2021-02-23 15:14:10 +01:00
case . activism :
2021-02-24 15:47:42 +01:00
return " ✊ "
2021-02-23 15:14:10 +01:00
case . food :
2021-02-24 15:47:42 +01:00
return " 🍕 "
2021-02-23 15:14:10 +01:00
case . furry :
2021-02-24 15:47:42 +01:00
return " 🦁 "
2021-02-23 15:14:10 +01:00
case . games :
2021-02-24 15:47:42 +01:00
return " 🕹 "
2021-02-23 15:14:10 +01:00
case . general :
return " GE "
case . journalism :
2021-02-24 15:47:42 +01:00
return " 📰 "
2021-02-23 15:14:10 +01:00
case . lgbt :
2021-02-24 15:47:42 +01:00
return " 🏳️🌈 "
2021-02-23 15:14:10 +01:00
case . regional :
return " 📍 "
case . art :
return " 🎨 "
case . music :
return " 🎼 "
case . tech :
return " 📱 "
case . _other :
2021-02-24 15:47:42 +01:00
return " ❓ "
2021-02-23 15:14:10 +01:00
}
}
}
}
let mode : PickServerMode
let context : AppContext
var categories = [ Category ] ( )
let selectCategoryIndex = CurrentValueSubject < Int , Never > ( 0 )
let searchText = CurrentValueSubject < String ? , Never > ( nil )
2021-02-24 15:47:42 +01:00
let allServers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Never > ( [ ] )
let searchedServers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Error > ( [ ] )
2021-02-23 15:14:10 +01:00
2021-02-25 07:09:19 +01:00
let selectedServer = CurrentValueSubject < Mastodon . Entity . Server ? , Never > ( nil )
let error = PassthroughSubject < Error , Never > ( )
let authenticated = PassthroughSubject < ( domain : String , account : Mastodon . Entity . Account ) , Never > ( )
2021-02-23 15:14:10 +01:00
2021-02-24 15:47:42 +01:00
private var disposeBag = Set < AnyCancellable > ( )
weak var tableView : UITableView ?
2021-02-25 07:09:19 +01:00
var mastodonPinBasedAuthenticationViewController : UIViewController ?
2021-02-23 15:14:10 +01:00
init ( context : AppContext , mode : PickServerMode ) {
self . context = context
self . mode = mode
super . init ( )
configure ( )
}
private func configure ( ) {
let masCategories = context . apiService . stubCategories ( )
2021-02-25 09:38:24 +01:00
categories . append ( . all )
categories . append ( contentsOf : masCategories . map { Category . some ( $0 ) } )
2021-02-24 15:47:42 +01:00
Publishers . CombineLatest3 (
selectCategoryIndex ,
searchText . debounce ( for : . milliseconds ( 300 ) , scheduler : DispatchQueue . main ) . removeDuplicates ( ) ,
allServers
)
2021-02-25 07:09:19 +01:00
. flatMap { [ weak self ] ( selectCategoryIndex , searchText , allServers ) -> AnyPublisher < Result < [ Mastodon . Entity . Server ] , Error > , Never > in
guard let self = self else { return Just ( Result . success ( [ ] ) ) . eraseToAnyPublisher ( ) }
2021-02-24 15:47:42 +01:00
// 1 . S e a r c h f r o m t h e s e r v e r s r e c o r d e d i n j o i n m a s t o d o n . o r g
let searchedServersFromAPI = self . searchServersFromAPI ( category : self . categories [ selectCategoryIndex ] , searchText : searchText , allServers : allServers )
if ! searchedServersFromAPI . isEmpty {
// I f f o u n d s e r v e r s , j u s t r e t u r n
2021-02-25 07:09:19 +01:00
return Just ( Result . success ( searchedServersFromAPI ) ) . eraseToAnyPublisher ( )
2021-02-24 15:47:42 +01:00
}
// 2 . N o s e r v e r f o u n d i n t h e r e c o r d e d l i s t , c h e c k i f s e a r c h T e x t i s a v a l i d m a s t o d o n s e r v e r d o m a i n
2021-02-25 10:29:04 +01:00
if let toSearchText = searchText , ! toSearchText . isEmpty , let _ = URL ( string : " https:// \( toSearchText ) " ) {
2021-02-24 15:47:42 +01:00
return self . context . apiService . instance ( domain : toSearchText )
2021-02-25 07:09:19 +01:00
. map { return Result . success ( [ Mastodon . Entity . Server ( instance : $0 . value ) ] ) }
. catch ( { error -> Just < Result < [ Mastodon . Entity . Server ] , Error > > in
return Just ( Result . failure ( error ) )
} )
. eraseToAnyPublisher ( )
2021-02-24 15:47:42 +01:00
}
2021-02-25 07:09:19 +01:00
return Just ( Result . success ( searchedServersFromAPI ) ) . eraseToAnyPublisher ( )
2021-02-24 15:47:42 +01:00
}
2021-02-25 07:09:19 +01:00
. sink { _ in
} receiveValue : { [ weak self ] result in
switch result {
case . success ( let servers ) :
self ? . searchedServers . send ( servers )
case . failure ( let error ) :
// TODO: W h a t s h o u l d b e p r e s e n t e d w h e n u s e r i n p u t s i n v a l i d s e a r c h t e x t ?
self ? . searchedServers . send ( [ ] )
}
2021-02-24 15:47:42 +01:00
}
. store ( in : & disposeBag )
}
func fetchAllServers ( ) {
context . apiService . servers ( language : nil , category : nil )
2021-02-25 09:38:24 +01:00
. sink { completion in
// TODO: A d d a r e l o a d b u t t o n w h e n f a i l s t o f e t c h s e r v e r s i n i t i a l l y
2021-02-24 15:47:42 +01:00
} receiveValue : { [ weak self ] result in
self ? . allServers . send ( result . value )
}
. store ( in : & disposeBag )
}
private func searchServersFromAPI ( category : Category , searchText : String ? , allServers : [ Mastodon . Entity . Server ] ) -> [ Mastodon . Entity . Server ] {
return allServers
// 1 . F i l t e r t h e c a t e g o r y
. filter {
switch category {
2021-02-25 09:38:24 +01:00
case . all :
2021-02-24 15:47:42 +01:00
return true
2021-02-25 09:38:24 +01:00
case . some ( let masCategory ) :
2021-02-24 15:47:42 +01:00
return $0 . category . caseInsensitiveCompare ( masCategory . category . rawValue ) = = . orderedSame
}
}
// 2 . F i l t e r t h e s e a r c h T e x t
. filter {
2021-02-25 07:09:19 +01:00
if let searchText = searchText , ! searchText . isEmpty {
return $0 . domain . lowercased ( ) . contains ( searchText . lowercased ( ) )
2021-02-24 15:47:42 +01:00
} else {
return true
}
}
2021-02-23 15:14:10 +01:00
}
}
2021-02-25 07:09:19 +01:00
// MARK: - S i g n I n m e t h o d s & s t r u c t s
2021-02-26 11:27:47 +01:00
extension MastodonPickServerViewModel {
2021-02-25 07:09:19 +01:00
enum AuthenticationError : Error , LocalizedError {
case badCredentials
case registrationClosed
var errorDescription : String ? {
switch self {
case . badCredentials : return " Bad Credentials "
case . registrationClosed : return " Registration Closed "
}
}
var failureReason : String ? {
switch self {
case . badCredentials : return " Credentials invalid. "
case . registrationClosed : return " Server disallow registration. "
}
}
var helpAnchor : String ? {
switch self {
case . badCredentials : return " Please try again. "
case . registrationClosed : return " Please try another domain. "
}
}
}
struct AuthenticateInfo {
let domain : String
let clientID : String
let clientSecret : String
let authorizeURL : URL
init ? ( domain : String , application : Mastodon . Entity . Application ) {
self . domain = domain
guard let clientID = application . clientID ,
let clientSecret = application . clientSecret else { return nil }
self . clientID = clientID
self . clientSecret = clientSecret
self . authorizeURL = {
let query = Mastodon . API . OAuth . AuthorizeQuery ( clientID : clientID )
let url = Mastodon . API . OAuth . authorizeURL ( domain : domain , query : query )
return url
} ( )
}
}
func authenticate ( info : AuthenticateInfo , pinCodePublisher : PassthroughSubject < String , Never > ) {
pinCodePublisher
. handleEvents ( receiveOutput : { [ weak self ] _ in
guard let self = self else { return }
// s e l f . i s A u t h e n t i c a t i n g . v a l u e = t r u e
self . mastodonPinBasedAuthenticationViewController ? . dismiss ( animated : true , completion : nil )
self . mastodonPinBasedAuthenticationViewController = nil
} )
. compactMap { [ weak self ] code -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > ? in
guard let self = self else { return nil }
return self . context . apiService
. userAccessToken (
domain : info . domain ,
clientID : info . clientID ,
clientSecret : info . clientSecret ,
code : code
)
. flatMap { response -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > in
let token = response . value
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: sign in success. Token: %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , token . accessToken )
return Self . verifyAndSaveAuthentication (
context : self . context ,
info : info ,
userToken : token
)
}
. eraseToAnyPublisher ( )
}
. switchToLatest ( )
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] completion in
guard let self = self else { return }
switch completion {
case . failure ( let error ) :
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , error . localizedDescription )
// s e l f . i s A u t h e n t i c a t i n g . v a l u e = f a l s e
self . error . send ( error )
case . finished :
break
}
} receiveValue : { [ weak self ] response in
guard let self = self else { return }
let account = response . value
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: user %s sign in success " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , account . username )
self . authenticated . send ( ( domain : info . domain , account : account ) )
}
. store ( in : & self . disposeBag )
}
static func verifyAndSaveAuthentication (
context : AppContext ,
info : AuthenticateInfo ,
userToken : Mastodon . Entity . Token
) -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > {
let authorization = Mastodon . API . OAuth . Authorization ( accessToken : userToken . accessToken )
let managedObjectContext = context . backgroundManagedObjectContext
return context . apiService . accountVerifyCredentials (
domain : info . domain ,
authorization : authorization
)
. flatMap { response -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > in
let account = response . value
let mastodonUserRequest = MastodonUser . sortedFetchRequest
mastodonUserRequest . predicate = MastodonUser . predicate ( domain : info . domain , id : account . id )
mastodonUserRequest . fetchLimit = 1
guard let mastodonUser = try ? managedObjectContext . fetch ( mastodonUserRequest ) . first else {
return Fail ( error : AuthenticationError . badCredentials ) . eraseToAnyPublisher ( )
}
let property = MastodonAuthentication . Property (
domain : info . domain ,
userID : mastodonUser . id ,
username : mastodonUser . username ,
appAccessToken : userToken . accessToken , // TODO: s w a p a p p t o k e n
userAccessToken : userToken . accessToken ,
clientID : info . clientID ,
clientSecret : info . clientSecret
)
return managedObjectContext . performChanges {
_ = APIService . CoreData . createOrMergeMastodonAuthentication (
into : managedObjectContext ,
for : mastodonUser ,
in : info . domain ,
property : property ,
networkDate : response . networkDate
)
}
. tryMap { result in
switch result {
case . failure ( let error ) : throw error
case . success : return response
}
}
. eraseToAnyPublisher ( )
}
. eraseToAnyPublisher ( )
}
}
// MARK: - S i g n U p m e t h o d s & s t r u c t s
2021-02-26 11:27:47 +01:00
extension MastodonPickServerViewModel {
2021-02-25 07:09:19 +01:00
struct SignUpResponseFirst {
let instance : Mastodon . Response . Content < Mastodon . Entity . Instance >
let application : Mastodon . Response . Content < Mastodon . Entity . Application >
}
struct SignUpResponseSecond {
let instance : Mastodon . Response . Content < Mastodon . Entity . Instance >
let authenticateInfo : AuthenticationViewModel . AuthenticateInfo
}
struct SignUpResponseThird {
let instance : Mastodon . Response . Content < Mastodon . Entity . Instance >
let authenticateInfo : AuthenticationViewModel . AuthenticateInfo
let applicationToken : Mastodon . Response . Content < Mastodon . Entity . Token >
2021-02-24 15:47:42 +01:00
}
}