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 .
//
2021-03-05 15:50:20 +01:00
import os . log
2021-02-23 15:14:10 +01:00
import UIKit
import Combine
2021-03-05 15:50:20 +01:00
import GameplayKit
2021-02-23 15:14:10 +01:00
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
}
2021-03-05 15:50:20 +01:00
var disposeBag = Set < AnyCancellable > ( )
// i n p u t
2021-02-23 15:14:10 +01:00
let mode : PickServerMode
let context : AppContext
2021-03-05 15:50:20 +01:00
var categoryPickerItems : [ CategoryPickerItem ] = {
var items : [ CategoryPickerItem ] = [ ]
items . append ( . all )
items . append ( contentsOf : APIService . stubCategories ( ) . map { CategoryPickerItem . category ( category : $0 ) } )
return items
} ( )
2021-02-23 15:14:10 +01:00
let selectCategoryIndex = CurrentValueSubject < Int , Never > ( 0 )
let searchText = CurrentValueSubject < String ? , Never > ( nil )
2021-03-05 15:50:20 +01:00
let indexedServers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Never > ( [ ] )
let unindexedServers = CurrentValueSubject < [ Mastodon . Entity . Instance ] , Never > ( [ ] )
2021-02-23 15:14:10 +01:00
2021-03-05 15:50:20 +01:00
// o u t p u t
var diffableDataSource : UITableViewDiffableDataSource < PickServerSection , PickServerItem > ?
private ( set ) lazy var loadIndexedServerStateMachine : GKStateMachine = {
// e x c l u d e t i m e l i n e m i d d l e f e t c h e r s t a t e
let stateMachine = GKStateMachine ( states : [
LoadIndexedServerState . Initial ( viewModel : self ) ,
LoadIndexedServerState . Loading ( viewModel : self ) ,
LoadIndexedServerState . Fail ( viewModel : self ) ,
LoadIndexedServerState . Idle ( viewModel : self ) ,
] )
stateMachine . enter ( LoadIndexedServerState . Initial . self )
return stateMachine
} ( )
2021-02-23 15:14:10 +01:00
2021-03-05 15:50:20 +01:00
let servers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Error > ( [ ] )
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-03-05 15:50:20 +01:00
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 ( ) {
2021-02-24 15:47:42 +01:00
Publishers . CombineLatest3 (
2021-03-05 15:50:20 +01:00
indexedServers ,
unindexedServers ,
searchText
2021-02-24 15:47:42 +01:00
)
2021-03-05 15:50:20 +01:00
. receive ( on : DispatchQueue . main )
. sink ( receiveValue : { [ weak self ] indexedServers , unindexedServers , searchText in
guard let self = self else { return }
guard let diffableDataSource = self . diffableDataSource else { return }
2021-02-24 15:47:42 +01:00
2021-03-05 15:50:20 +01:00
let oldSnapshot = diffableDataSource . snapshot ( )
var oldSnapshotServerItemAttributeDict : [ String : PickServerItem . ServerItemAttribute ] = [ : ]
for item in oldSnapshot . itemIdentifiers {
guard case let . server ( server , attribute ) = item else { continue }
oldSnapshotServerItemAttributeDict [ server . domain ] = attribute
2021-02-24 15:47:42 +01:00
}
2021-02-25 07:09:19 +01:00
2021-03-05 15:50:20 +01:00
var snapshot = NSDiffableDataSourceSnapshot < PickServerSection , PickServerItem > ( )
snapshot . appendSections ( [ . header , . category , . search , . servers ] )
snapshot . appendItems ( [ . header ] , toSection : . header )
snapshot . appendItems ( [ . categoryPicker ( items : self . categoryPickerItems ) ] , toSection : . category )
snapshot . appendItems ( [ . search ] , toSection : . search )
// TODO: h a n d l e f i l t e r
var serverItems : [ PickServerItem ] = [ ]
for server in indexedServers {
let attribute = oldSnapshotServerItemAttributeDict [ server . domain ] ? ? PickServerItem . ServerItemAttribute ( isExpand : false )
let item = PickServerItem . server ( server : server , attribute : attribute )
serverItems . append ( item )
2021-02-25 07:09:19 +01:00
}
2021-03-05 15:50:20 +01:00
snapshot . appendItems ( serverItems , toSection : . servers )
2021-02-25 07:09:19 +01:00
2021-03-05 15:50:20 +01:00
diffableDataSource . apply ( snapshot )
} )
2021-02-24 15:47:42 +01:00
. store ( in : & disposeBag )
2021-03-05 15:50:20 +01:00
// P u b l i s h e r s . C o m b i n e L a t e s t 3 (
// s e l e c t C a t e g o r y I n d e x ,
// s e a r c h T e x t . d e b o u n c e ( f o r : . m i l l i s e c o n d s ( 3 0 0 ) , s c h e d u l e r : D i s p a t c h Q u e u e . m a i n ) . r e m o v e D u p l i c a t e s ( ) ,
// i n d e x e d S e r v e r s
// )
// . f l a t M a p { [ w e a k s e l f ] ( s e l e c t C a t e g o r y I n d e x , s e a r c h T e x t , a l l S e r v e r s ) - > A n y P u b l i s h e r < R e s u l t < [ M a s t o d o n . E n t i t y . S e r v e r ] , E r r o r > , N e v e r > i n
// g u a r d l e t s e l f = s e l f e l s e { r e t u r n J u s t ( R e s u l t . s u c c e s s ( [ ] ) ) . e r a s e T o A n y P u b l i s h e r ( ) }
//
// / / 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
// l e t s e a r c h e d S e r v e r s F r o m A P I = s e l f . s e a r c h S e r v e r s F r o m A P I ( c a t e g o r y : s e l f . c a t e g o r i e s [ s e l e c t C a t e g o r y I n d e x ] , s e a r c h T e x t : s e a r c h T e x t , a l l S e r v e r s : a l l S e r v e r s )
// i f ! s e a r c h e d S e r v e r s F r o m A P I . i s E m p t y {
// / / I f f o u n d s e r v e r s , j u s t r e t u r n
// r e t u r n J u s t ( R e s u l t . s u c c e s s ( s e a r c h e d S e r v e r s F r o m A P I ) ) . e r a s e T o A n y P u b l i s h e r ( )
// }
// / / 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
// i f l e t t o S e a r c h T e x t = s e a r c h T e x t , ! t o S e a r c h T e x t . i s E m p t y , l e t _ = U R L ( s t r i n g : " h t t p s : / / \ ( t o S e a r c h T e x t ) " ) {
// r e t u r n s e l f . c o n t e x t . a p i S e r v i c e . i n s t a n c e ( d o m a i n : t o S e a r c h T e x t )
// . m a p { r e t u r n R e s u l t . s u c c e s s ( [ M a s t o d o n . E n t i t y . S e r v e r ( i n s t a n c e : $ 0 . v a l u e ) ] ) }
// . c a t c h ( { e r r o r - > J u s t < R e s u l t < [ M a s t o d o n . E n t i t y . S e r v e r ] , E r r o r > > i n
// r e t u r n J u s t ( R e s u l t . f a i l u r e ( e r r o r ) )
// } )
// . e r a s e T o A n y P u b l i s h e r ( )
// }
// r e t u r n J u s t ( R e s u l t . s u c c e s s ( s e a r c h e d S e r v e r s F r o m A P I ) ) . e r a s e T o A n y P u b l i s h e r ( )
// }
// . s i n k { _ i n
//
// } r e c e i v e V a l u e : { [ w e a k s e l f ] r e s u l t i n
// s w i t c h r e s u l t {
// c a s e . s u c c e s s ( l e t s e r v e r s ) :
// s e l f ? . s e r v e r s . s e n d ( s e r v e r s )
// c a s e . f a i l u r e ( l e t e r r o r ) :
// / / 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 ?
// s e l f ? . s e r v e r s . s e n d ( [ ] )
// }
//
// }
// . s t o r e ( i n : & d i s p o s e B a g )
2021-02-24 15:47:42 +01:00
}
2021-03-05 15:50:20 +01:00
// f u n c f e t c h A l l S e r v e r s ( ) {
// c o n t e x t . a p i S e r v i c e . s e r v e r s ( l a n g u a g e : n i l , c a t e g o r y : n i l )
// . s i n k { c o m p l e t i o n i n
// / / 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
// } r e c e i v e V a l u e : { [ w e a k s e l f ] r e s u l t i n
// s e l f ? . i n d e x e d S e r v e r s . s e n d ( r e s u l t . v a l u e )
// }
// . s t o r e ( i n : & d i s p o s e B a g )
//
// }
//
// p r i v a t e f u n c s e a r c h S e r v e r s F r o m A P I ( c a t e g o r y : C a t e g o r y , s e a r c h T e x t : S t r i n g ? , a l l S e r v e r s : [ M a s t o d o n . E n t i t y . S e r v e r ] ) - > [ M a s t o d o n . E n t i t y . S e r v e r ] {
// r e t u r n a l l S e r v e r s
// / / 1 . F i l t e r t h e c a t e g o r y
// . f i l t e r {
// s w i t c h c a t e g o r y {
// c a s e . a l l :
// r e t u r n t r u e
// c a s e . s o m e ( l e t m a s C a t e g o r y ) :
// r e t u r n $ 0 . c a t e g o r y . c a s e I n s e n s i t i v e C o m p a r e ( m a s C a t e g o r y . c a t e g o r y . r a w V a l u e ) = = . o r d e r e d S a m e
// }
// }
// / / 2 . F i l t e r t h e s e a r c h T e x t
// . f i l t e r {
// i f l e t s e a r c h T e x t = s e a r c h T e x t , ! s e a r c h T e x t . i s E m p t y {
// r e t u r n $ 0 . d o m a i n . l o w e r c a s e d ( ) . c o n t a i n s ( s e a r c h T e x t . l o w e r c a s e d ( ) )
// } e l s e {
// r e t u r n t r u e
// }
// }
// }
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
}
}